2026-07-21 15:05:49 +02:00
|
|
|
import json
|
|
|
|
|
|
2026-07-21 10:26:31 +02:00
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
|
|
|
from pydantic import BaseModel
|
2026-07-21 14:21:55 +02:00
|
|
|
from sqlalchemy import select
|
2026-07-21 10:26:31 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-07-21 14:21:55 +02:00
|
|
|
from app.audit.log import write_audit_log
|
2026-07-21 10:26:31 +02:00
|
|
|
from app.config import settings
|
2026-07-21 15:05:49 +02:00
|
|
|
from app.db.models import AuditLog, PendingTransaction, Round, User
|
2026-07-21 10:26:31 +02:00
|
|
|
from app.db.session import get_session
|
|
|
|
|
from app.rounds.config import get_round_config
|
2026-07-21 14:21:55 +02:00
|
|
|
from app.wallet.hd import derive_user_wif
|
2026-07-21 10:26:31 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
|
|
|
|
if not settings.admin_token or x_admin_token != settings.admin_token:
|
|
|
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 16:04:02 +02:00
|
|
|
_CONFIG_FIELDS = (
|
|
|
|
|
"fee_address",
|
|
|
|
|
"bet_amount_sats",
|
|
|
|
|
"round_duration_seconds",
|
|
|
|
|
"round_cooldown_seconds",
|
|
|
|
|
"min_amount_sats",
|
|
|
|
|
"fee_rate_sat_vb",
|
|
|
|
|
"rbf_timeout_seconds",
|
|
|
|
|
"draw_animation_seconds",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 10:26:31 +02:00
|
|
|
class RoundConfigResponse(BaseModel):
|
|
|
|
|
fee_address: str
|
|
|
|
|
bet_amount_sats: int
|
2026-07-21 15:05:49 +02:00
|
|
|
round_duration_seconds: int
|
|
|
|
|
round_cooldown_seconds: int
|
|
|
|
|
min_amount_sats: int
|
|
|
|
|
fee_rate_sat_vb: int
|
|
|
|
|
rbf_timeout_seconds: int
|
2026-07-21 16:04:02 +02:00
|
|
|
draw_animation_seconds: int
|
2026-07-21 10:26:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class RoundConfigUpdate(BaseModel):
|
|
|
|
|
fee_address: str | None = None
|
|
|
|
|
bet_amount_sats: int | None = None
|
2026-07-21 15:05:49 +02:00
|
|
|
round_duration_seconds: int | None = None
|
|
|
|
|
round_cooldown_seconds: int | None = None
|
|
|
|
|
min_amount_sats: int | None = None
|
|
|
|
|
fee_rate_sat_vb: int | None = None
|
|
|
|
|
rbf_timeout_seconds: int | None = None
|
2026-07-21 16:04:02 +02:00
|
|
|
draw_animation_seconds: int | None = None
|
2026-07-21 15:05:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _config_response(config) -> RoundConfigResponse:
|
2026-07-21 16:04:02 +02:00
|
|
|
return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_FIELDS})
|
2026-07-21 10:26:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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()
|
2026-07-21 15:05:49 +02:00
|
|
|
return _config_response(config)
|
2026-07-21 10:26:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-07-21 16:04:02 +02:00
|
|
|
for field in _CONFIG_FIELDS:
|
2026-07-21 15:05:49 +02:00
|
|
|
value = getattr(body, field)
|
|
|
|
|
if value is not None:
|
|
|
|
|
setattr(config, field, value)
|
2026-07-21 10:26:31 +02:00
|
|
|
await session.commit()
|
2026-07-21 15:05:49 +02:00
|
|
|
return _config_response(config)
|
2026-07-21 14:21:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-21 15:05:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
]
|