Files
plm-lottery/app/api/routes/admin.py
T
davideandClaude Sonnet 5.5 54dbffb552 Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a
private chain: the bet left the chain but its participant stayed `confirmed`, and the payout
failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs
were also credited at 1 confirmation (maturity is 120).

Deposits
- `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the
  threshold for a deposit; an output of a platform transaction (any txid of a
  pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120,
  recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached).
  `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no
  session is open across the lookup.
- The quorum must also place the coin deep enough on our own tip (ELC-17).
- Confirmed outputs short of their threshold are pending deposits (memory only, replaced at
  every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal
  message), never spendable. No scripthash notification announces a maturing deposit, so each
  new block refreshes only the users that have one, one task per user (DEP-09 now says so).

Payout
- `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other
  servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a
  mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints
  (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool
  verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one
  `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same
  transaction, and the 70/30 split is computed on what is left.
- Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93,
  with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with
  `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears
  the mark and the next attempt verifies again. /admin/rounds returns it.
- Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server
  `listunspent` choice, since that address holds no single round's bets.

Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every
participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to
repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says
"1 confirmation" (7 languages, same keys); guides updated.

Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0
now set a tip past every threshold; the payout fixtures seed confirmed participants (the new
source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the
verification, and test_round_pool_address asserts the quorum is asked about the round's
address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching
or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and
disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with
the split recomputed, winner gone held and paused, held payouts untouched until the resume,
re-verification after it, no quorum, a payout with 1 and with 400 participants against
reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored
coinbase, no height bound, no verification and no winner hold each fail tests.

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

491 lines
19 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 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)
)
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)
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)
@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:
problem = payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb)
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,
)
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)