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 pause_by_operator 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 pause_by_operator(session, body.note if body is not None else None) 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)