Files
plm-lottery/app/api/routes/admin.py
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

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

518 lines
20 KiB
Python

import json
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password_async
from app.config import settings
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
from app.rounds.service import get_active_round, payout_config_problem
from app.wallet.address import is_valid_plm_address
from app.wallet.hd import derive_user_wif
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
router = APIRouter(prefix="/admin", tags=["admin"])
async def require_admin(x_admin_token: str = Header(default="")) -> None:
# An unset ADMIN_TOKEN denies everything — checked first, since compare_digest
# on two empty strings returns True and would otherwise open the panel to
# anyone on an instance that never configured a token.
if not settings.admin_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
# compare_digest raises TypeError on a str containing non-ASCII characters
# (B-46) -- comparing the UTF-8 bytes instead accepts any input safely.
if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()):
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
# (/admin/pause, /admin/resume), and accepting it on PUT /config as well gave the
# operator an unlogged way to stop the lottery (B-10). It stays in the response
# model, so the dashboard still reads its current value from here.
_CONFIG_FIELDS = (
"fee_address",
"bet_amount_sats",
"round_duration_seconds",
"round_cooldown_seconds",
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"deposit_confirmations", # B-82 (DAT-62)
"draw_confirmations", # B-73 (DAT-62)
"max_round_pool_sats", # B-95 (DAT-62)
)
class RoundConfigResponse(BaseModel):
fee_address: str
bet_amount_sats: int
round_duration_seconds: int
round_cooldown_seconds: int
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
deposit_confirmations: int # B-82 (DAT-62)
draw_confirmations: int # B-73 (DAT-62)
max_round_pool_sats: int # B-95 (DAT-62)
paused: bool
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
class RoundConfigUpdate(BaseModel):
"""Bounds are enforced here rather than trusting the operator: a value like
fee_rate_sat_vb=0 produces transactions no node will relay (stalling every bet,
payout and withdrawal), and round_duration_seconds=0 expires a round the instant
it opens. `paused` is not accepted — see _CONFIG_FIELDS."""
# An unvalidated fee_address was the worst of the lot: a malformed one wedged the
# payout with an unhandled EmbitError, and a well-formed *foreign* one (bc1...)
# parses fine as a witness program, so every round's 30 % commission would be
# broadcast to a script nobody holds the key for (B-05).
fee_address: str | None = None
bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000)
round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600)
round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600)
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
max_round_pool_sats: int | None = Field(default=None, ge=0, le=4_000_000_000_000_000) # B-95 (DAT-62)
@field_validator("fee_address")
@classmethod
def _validate_fee_address(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not is_valid_plm_address(value):
raise ValueError(
"fee_address must be a valid PLM bech32 address (plm1...) — an address from "
"another chain would send every round's commission somewhere unspendable"
)
return value
def _config_response(config) -> RoundConfigResponse:
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
fields["paused"] = config.paused
fields["paused_reason"] = config.paused_reason
return RoundConfigResponse(**fields)
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
config = await get_round_config(session)
await session.commit()
return _config_response(config)
@router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def update_config(
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
) -> RoundConfigResponse:
config = await get_round_config(session)
# Diff computed before assignment so the audit entry records both sides. Without
# it, the most sensitive setting in the system (fee_address — where 30 % of every
# pool goes) could be changed without leaving any trace at all (B-10).
changes: dict[str, dict] = {}
for field in _CONFIG_FIELDS:
value = getattr(body, field)
if value is None:
continue
previous = getattr(config, field)
if previous == value:
continue
changes[field] = {"from": previous, "to": value}
setattr(config, field, value)
# B-79 (RND-40): a bet amount or fee rate under which a round could not be paid out is
# refused before anything is written — the payout reads fee_rate_sat_vb at build time, so
# a rate raised mid-round could otherwise make the round in progress unpayable.
if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes:
amounts = {config.bet_amount_sats}
if "fee_rate_sat_vb" in changes:
# B-95: the round in progress is paid with the bet amount it opened with, which may
# differ from the one configured now — a higher fee rate has to leave *it* payable too.
active = await get_active_round(session)
if active is not None:
amounts.add(active.bet_amount_sats)
problem = next(
(p for p in (payout_config_problem(a, config.fee_rate_sat_vb) for a in sorted(amounts)) if p), None
)
if problem is not None:
await session.rollback()
raise http_error(
422,
"config_not_payable",
"this bet amount and fee rate would make a round impossible to pay out "
f"({problem}); a larger bet amount or a lower fee rate is needed",
reason=problem,
)
# B-95 (DAT-62): a cap below the price of one bet would refuse every bet of every round.
if (
("max_round_pool_sats" in changes or "bet_amount_sats" in changes)
and config.max_round_pool_sats
and config.max_round_pool_sats < config.bet_amount_sats
):
offending = {"max_round_pool_sats": config.max_round_pool_sats, "bet_amount_sats": config.bet_amount_sats}
await session.rollback()
raise http_error(
422,
"max_round_pool_below_bet_amount",
"max_round_pool_sats must be 0 (no cap) or at least the bet amount",
**offending,
)
if changes:
await write_audit_log(session, "config_updated", changes)
await session.commit()
return _config_response(config)
class PauseRequest(BaseModel):
note: str | None = Field(default=None, max_length=256)
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def pause_lottery(
body: PauseRequest | None = None, session: AsyncSession = Depends(get_session)
) -> RoundConfigResponse:
"""Maintenance switch: the round in progress (if any) still closes, draws,
and pays out its winner normally — only opening the *next* round is
suppressed until /admin/resume is called (rounds/service.py).
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
already in force stays — it is the one that needs a look. The audit row always
says `operator` for this call."""
config = await get_round_config(session)
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
config.paused_reason = OPERATOR_REASON
config.paused = True
payload = {"reason": OPERATOR_REASON}
if body is not None and body.note:
payload["note"] = body.note
await write_audit_log(session, "lottery_paused", payload)
await session.commit()
return _config_response(config)
@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
config = await get_round_config(session)
# B-78 (AUD-12): the reason the resume took away, so the log says what the operator
# agreed to resume (None when the lottery was not paused).
lifted = config.paused_reason if config.paused else None
config.paused = False
config.paused_reason = None
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
await session.commit()
return _config_response(config)
class AdminUserResponse(BaseModel):
id: int
username: str
address: str
balance_sats: int
created_at: str
@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)])
async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]:
users = (await session.scalars(select(User).order_by(User.id))).all()
return [
AdminUserResponse(
id=u.id,
username=u.username,
address=u.address,
balance_sats=u.cached_balance_sats,
created_at=isoformat_utc(u.created_at),
)
for u in users
]
class AdminPrivkeyResponse(BaseModel):
address: str
wif: str
@router.get(
"/users/{user_id}/privkey", response_model=AdminPrivkeyResponse, dependencies=[Depends(require_admin)]
)
async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session)) -> AdminPrivkeyResponse:
"""Exports a user's raw private key for manual intervention (e.g. sweeping
funds back if something's stuck). Every access is audit-logged since this is
the most sensitive data the platform holds."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
wif = derive_user_wif(user.derivation_index)
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
await session.commit()
return AdminPrivkeyResponse(address=user.address, wif=wif)
class AdminPasswordResetResponse(BaseModel):
username: str
new_password: str
@router.post(
"/users/{user_id}/reset-password",
response_model=AdminPasswordResetResponse,
dependencies=[Depends(require_admin)],
)
async def reset_user_password(
user_id: int, session: AsyncSession = Depends(get_session)
) -> AdminPasswordResetResponse:
"""Admin-only password reset for a user who's locked out: passwords are
Argon2-hashed (one-way), so an existing password can never be recovered or
displayed — this generates and sets a brand new one instead, shown once so
the admin can relay it to the user. There is no user-facing self-service
reset; only an admin (via /admin, token-gated) can trigger this."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
new_password = secrets.token_urlsafe(12)
user.password_hash = await hash_password_async(new_password)
# B-34: this endpoint exists precisely for the "account compromised" case —
# without bumping token_version, whoever was already logged in (the
# attacker, if that's who prompted the reset) stayed logged in on their
# existing token until it naturally expired, unaffected by the reset.
user.token_version += 1
await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id)
await session.commit()
return AdminPasswordResetResponse(username=user.username, new_password=new_password)
class AdminRoundResponse(BaseModel):
id: int
status: str
opened_at: str
closed_at: str | None
draw_block_height: int | None
draw_block_hash: str | None
winner_user_id: int | None
winner_username: str | None
pool_amount_sats: int | None
winner_amount_sats: int | None
fee_amount_sats: int | None
payout_txid: str | None
pool_address: str | None # B-80
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
bump_reserve_sats: int | None # B-79 (API-36)
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
async def list_rounds(
session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500)
) -> list[AdminRoundResponse]:
rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all()
winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None}
winners = {}
if winner_ids:
users = (await session.scalars(select(User).where(User.id.in_(winner_ids)))).all()
winners = {u.id: u.username for u in users}
return [
AdminRoundResponse(
id=r.id,
status=r.status,
opened_at=isoformat_utc(r.opened_at),
closed_at=isoformat_utc(r.closed_at),
draw_block_height=r.draw_block_height,
draw_block_hash=r.draw_block_hash,
winner_user_id=r.winner_user_id,
winner_username=winners.get(r.winner_user_id) if r.winner_user_id is not None else None,
pool_amount_sats=r.pool_amount_sats,
winner_amount_sats=r.winner_amount_sats,
fee_amount_sats=r.fee_amount_sats,
payout_txid=r.payout_txid,
pool_address=r.pool_address or None,
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
bump_reserve_sats=r.bump_reserve_sats,
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
)
for r in rounds
]
class AdminAuditLogResponse(BaseModel):
id: int
event_type: str
payload: dict
user_id: int | None
round_id: int | None
created_at: str
@router.get(
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
) -> list[AdminAuditLogResponse]:
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
return [
AdminAuditLogResponse(
id=e.id,
event_type=e.event_type,
payload=json.loads(e.payload_json),
user_id=e.user_id,
round_id=e.round_id,
created_at=isoformat_utc(e.created_at),
)
for e in entries
]
class AdminPendingTransactionResponse(BaseModel):
id: int
kind: str
status: str
round_id: int | None
withdrawal_id: int | None
user_id: int | None
current_txid: str
fee_rate_sat_vb: int
attempt_count: int
broadcast_at: str
replaced_by_txid: str | None
@router.get(
"/pending-transactions",
response_model=list[AdminPendingTransactionResponse],
dependencies=[Depends(require_admin)],
)
async def list_pending_transactions(
session: AsyncSession = Depends(get_session),
limit: int = Query(default=50, ge=1, le=500),
status_filter: str | None = Query(default=None, alias="status"),
) -> list[AdminPendingTransactionResponse]:
query = select(PendingTransaction).order_by(PendingTransaction.id.desc())
if status_filter is not None:
query = query.where(PendingTransaction.status == status_filter)
entries = (await session.scalars(query.limit(limit))).all()
return [
AdminPendingTransactionResponse(
id=p.id,
kind=p.kind,
status=p.status,
round_id=p.round_id,
withdrawal_id=p.withdrawal_id,
user_id=p.user_id,
current_txid=p.current_txid,
fee_rate_sat_vb=p.fee_rate_sat_vb,
attempt_count=p.attempt_count,
broadcast_at=isoformat_utc(p.broadcast_at),
replaced_by_txid=p.replaced_by_txid,
)
for p in entries
]
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
class AdminBugReportResponse(BaseModel):
id: int
description: str
contact: str | None
user_id: int | None
username: str | None
status: str
created_at: str
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
return AdminBugReportResponse(
id=report.id,
description=report.description,
contact=report.contact,
user_id=report.user_id,
username=username,
status=report.status,
created_at=isoformat_utc(report.created_at),
)
@router.get(
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
)
async def list_bug_reports(
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
) -> list[AdminBugReportResponse]:
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
user_ids = {r.user_id for r in reports if r.user_id is not None}
usernames = {}
if user_ids:
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
usernames = {u.id: u.username for u in users}
return [
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
for r in reports
]
class BugReportStatusUpdate(BaseModel):
status: str = Field(pattern="^(open|read|resolved)$")
@router.post(
"/bug-reports/{report_id}/status",
response_model=AdminBugReportResponse,
dependencies=[Depends(require_admin)],
)
async def update_bug_report_status(
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
) -> AdminBugReportResponse:
report = await session.get(BugReport, report_id)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
# password reset) leaves a trace; this one silently marked a report `resolved`.
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
# only accountability there is. Before/after like config_updated, and nothing
# written when the status doesn't actually change — re-clicking the status a
# report already has isn't an event.
if report.status != body.status:
await write_audit_log(
session,
"bug_report_status_changed",
{"report_id": report_id, "from": report.status, "to": body.status},
user_id=report.user_id,
)
report.status = body.status
await session.commit()
username = None
if report.user_id is not None:
user = await session.get(User, report.user_id)
username = user.username if user is not None else None
return _bug_report_response(report, username)