START_PAUSED=true pauses the lottery as the operator before the scheduler exists. On the first Electrum session the 20 derivation indexes past the highest assigned one are looked up on every server; if any has history, registration stays closed (503 registration_unavailable) and restore_gap_detected is written, so a restored database cannot hand a lost user's address to a new one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
511 lines
20 KiB
Python
511 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 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)
|