Files
plm-lottery/app/api/routes/admin.py
T
davide 17c557b8a3 Meet BIP125's relay minimum on every RBF bump, and cap the fee rate (B-32)
bump_fee computed fee_delta as new_fee - old_fee, falling back to a
flat 1-satoshi bump whenever that came out zero or negative - which
happened whenever old_fee (the actual fee paid, from real prevout
amounts) already exceeded the naive target, e.g. because dust change
had been folded into the original fee (psbt_builder.py's
DUST_LIMIT_SATS handling). A 1-satoshi total increase is nowhere near
BIP125 rule 4's minimum (the replacement must pay at least the
incremental relay fee rate times its own vsize more than what it
replaces), so the node rejected it every time - and since bump_fee
raised before touching `pending`, the next tick retried with identical
parameters every 30 seconds, forever. Separately, the fee rate climbed
by 1 sat/vB every bump with no ceiling.

fee_delta is now max(target_fee - old_fee, vsize * the incremental
relay rate) - always at least the relay-mandated minimum regardless of
what the naive arithmetic produces. pending.fee_rate_sat_vb is set to
the actual resulting rate rather than the naive target, so a later
bump's arithmetic starts from what's really being paid instead of
drifting from it. Once a transaction reaches MAX_FEE_RATE_SAT_VB (a
new constant, 10,000 sat/vB, shared with RoundConfig.fee_rate_sat_vb's
existing admin-facing bound so the two can't drift apart - the same
reason MIN_PASSWORD_LENGTH is shared elsewhere) bump_fee refuses to
bump further; the reconciler abandons it if it never confirms (B-27)
instead of this retrying forever.

Suite grows from 185 to 187 tests. BUGS.md moves B-32 to Previously
fixed.
2026-07-27 11:16:20 +02:00

336 lines
12 KiB
Python

import json
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.config import settings
from app.db.models import AuditLog, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
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")
if not secrets.compare_digest(x_admin_token, settings.admin_token):
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",
)
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
paused: bool
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)
@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
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)
if changes:
await write_audit_log(session, "config_updated", changes)
await session.commit()
return _config_response(config)
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def pause_lottery(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)."""
config = await get_round_config(session)
config.paused = True
await write_audit_log(session, "lottery_paused", {})
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)
config.paused = False
await write_audit_log(session, "lottery_resumed", {})
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=u.created_at.isoformat(),
)
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 = hash_password(new_password)
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
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> 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=r.opened_at.isoformat(),
closed_at=r.closed_at.isoformat() if r.closed_at else None,
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,
)
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 = 200
) -> 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=e.created_at.isoformat(),
)
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),
) -> list[AdminPendingTransactionResponse]:
entries = (
await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc()))
).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=p.broadcast_at.isoformat(),
replaced_by_txid=p.replaced_by_txid,
)
for p in entries
]