The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
464 lines
18 KiB
Python
464 lines
18 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
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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 AUTOMATIC_REASONS, OPERATOR_REASON
|
|
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",
|
|
"draw_confirmations", # B-73 (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
|
|
draw_confirmations: int # B-73 (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)
|
|
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (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)
|
|
|
|
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 get_round_config(session)
|
|
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
|
|
config.paused_reason = OPERATOR_REASON
|
|
config.paused = True
|
|
payload = {"reason": OPERATOR_REASON}
|
|
if body is not None and body.note:
|
|
payload["note"] = body.note
|
|
await write_audit_log(session, "lottery_paused", payload)
|
|
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
|
|
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
|
|
|
|
|
|
@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,
|
|
)
|
|
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)
|