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.
336 lines
12 KiB
Python
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
|
|
]
|