Files
plm-lottery/app/db/models.py
T

238 lines
13 KiB
Python
Raw Normal View History

from datetime import datetime, timezone
from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
# B-57: usernames are compared case-insensitively, and that has to be the
# database's job, not a convention the query layer remembers. "Bob" and "bob"
# used to be two accounts sharing one rate-limit bucket (each locking the other
# out) and, worse on a custodial system, a ready-made impersonation vector.
# A functional unique index rather than a normalized column: the name stays
# stored exactly as the user typed it, which is what /admin and the audit log
# display. The username pattern (auth/routes.py) is ASCII-only, so lower() is
# the whole of the normalization — no Unicode casefolding subtleties apply.
__table_args__ = (Index("ix_users_username_lower", text("lower(username)"), unique=True),)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
derivation_index: Mapped[int] = mapped_column(unique=True)
address: Mapped[str] = mapped_column(String(128), unique=True)
# Read cache only; must always be written in the same transaction as the
# utxo_events rows it summarizes. Source of truth is utxo_events.
cached_balance_sats: Mapped[int] = mapped_column(BigInteger, default=0)
# Embedded in every issued JWT (app/auth/security.py) and checked on every
# request (app/auth/dependencies.py:get_current_user). Bumped on a
# self-service or admin password change so every token issued before that
# point stops working immediately, instead of staying valid for up to
# jwt_expire_minutes after a compromised account's password is reset (B-34).
token_version: Mapped[int] = mapped_column(default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class UtxoEvent(Base):
__tablename__ = "utxo_events"
__table_args__ = (UniqueConstraint("txid", "vout"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
txid: Mapped[str] = mapped_column(String(64))
vout: Mapped[int]
amount_sats: Mapped[int] = mapped_column(BigInteger)
confirmed_height: Mapped[int]
confirmed_at: Mapped[datetime] = mapped_column(default=utcnow)
# Set once this UTXO is consumed by an outgoing bet/withdrawal build.
spent_txid: Mapped[str | None] = mapped_column(String(64), default=None)
_ACTIVE_ROUND_STATUSES_SQL = "'open', 'closing', 'drawing', 'paying_out'"
class Round(Base):
__tablename__ = "rounds"
# At most one round may be active at a time. Rounds never overlap by design,
# but that was enforced only by a read-then-insert in
# rounds/service.open_new_round_if_needed, which two concurrent callers can
# both pass — and a second stuck "open" row blocks every future round forever
# (B-09). This is the database-level guarantee: a unique index over a constant
# expression, restricted to the active statuses, so the table can hold any
# number of closed rounds and only ever one live one.
__table_args__ = (
Index(
"ix_rounds_single_active",
text("(1)"),
unique=True,
sqlite_where=text(f"status IN ({_ACTIVE_ROUND_STATUSES_SQL})"),
postgresql_where=text(f"status IN ({_ACTIVE_ROUND_STATUSES_SQL})"),
),
)
id: Mapped[int] = mapped_column(primary_key=True)
status: Mapped[str] = mapped_column(String(16), default="open")
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(default=None)
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
# Read live from the config, a mid-round edit applied retroactively: lowering
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
# instantly, and raising it moved the closes_at every client was already
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
# The config row is now what the *next* round opens with; these are what this
# round runs by. cooldown_seconds is read off the round that just closed, so
# the gap it announced is the gap that's honoured.
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
2026-07-27 14:12:27 +02:00
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
# (draw_waiting_since) measure how long a round has been waiting on a block,
# since that wait has no timeout of its own — see _wait_for_next_block.
drawing_started_at: Mapped[datetime | None] = mapped_column(default=None)
draw_block_height: Mapped[int | None] = mapped_column(default=None)
draw_block_hash: Mapped[str | None] = mapped_column(String(64), default=None)
seed_int: Mapped[str | None] = mapped_column(String(128), default=None)
winner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
pool_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
class RoundParticipant(Base):
__tablename__ = "round_participants"
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
bet_txid: Mapped[str] = mapped_column(String(64))
# Ordering / tie-break field per spec: broadcast time, not confirmation time.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
status: Mapped[str] = mapped_column(String(16), default="broadcast")
class RoundConfig(Base):
"""Single-row operational config, DB-backed so it's editable without a redeploy.
Everything business/round-related lives here (round timing, bet amount, fee
rate, RBF timeout) so an operator can tune it live. Secrets
and infra wiring (master key, JWT secret, Electrum host, admin token,
database URL) deliberately stay env-var-driven — those require a restart
anyway and aren't safe to hot-swap."""
__tablename__ = "round_config"
id: Mapped[int] = mapped_column(primary_key=True)
fee_address: Mapped[str] = mapped_column(String(128))
bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000)
round_duration_seconds: Mapped[int] = mapped_column(default=600)
round_cooldown_seconds: Mapped[int] = mapped_column(default=30)
# Purely a frontend cue: the minimum time the "estrazione in corso" animation
# plays for on every user's dashboard before the winner can be revealed. Does
# NOT gate the actual draw, which still waits for a real confirmed block for
# its entropy (rounds/scheduler.py) — that can take longer than this value.
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
# Maintenance switch: when true, the round currently in progress still runs to
# completion (closes, draws, pays out the winner) but no new round is opened
# afterwards — see rounds/service.py:open_new_round_if_needed.
paused: Mapped[bool] = mapped_column(default=False)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class PendingTransaction(Base):
"""Single source of truth for the RBF timeout->bump->rebroadcast loop, and the
row the reconciler (app/tx/reconcile.py) resolves against the chain.
Status lifecycle:
building -> written before the tx is broadcast, so a crash between the two
leaves evidence instead of a silently-spent UTXO set (B-08).
pending -> broadcast, waiting for its 1st confirmation.
confirmed -> terminal, set by app/tx/confirmation.py.
failed -> terminal, set by the reconciler when the tx is gone from the
chain for good; its UTXOs have been released by then.
"""
__tablename__ = "pending_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column(String(16)) # bet | payout | withdrawal
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
withdrawal_id: Mapped[int | None] = mapped_column(ForeignKey("withdrawals.id"), default=None)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
current_txid: Mapped[str] = mapped_column(String(64))
fee_rate_sat_vb: Mapped[int]
raw_tx_hex: Mapped[str] = mapped_column(Text)
# The *first* broadcast — never rewritten by a bump — since this is what the
# reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures
# from. Bumping used to overwrite this field, which reset that clock on every
# bump and meant a repeatedly-bumped-but-never-mined tx was never abandoned
# (B-27). last_broadcast_at is the one bump_fee updates, and the one should_bump
# (app/tx/broadcast.py) reads to decide whether another bump is due.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
status: Mapped[str] = mapped_column(String(16), default="pending")
# The txid this row had *before* its most recent RBF bump (bump_fee rewrites
# current_txid in place). Despite the name reading forwards, it points
# backwards: current_txid is the replacement, this is what it replaced.
replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None)
attempt_count: Mapped[int] = mapped_column(default=1)
# Why the reconciler gave up on this tx — operator-facing, only set on "failed".
failure_reason: Mapped[str | None] = mapped_column(String(128), default=None)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class Withdrawal(Base):
__tablename__ = "withdrawals"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
external_address: Mapped[str] = mapped_column(String(128))
amount_requested_sats: Mapped[int] = mapped_column(BigInteger)
amount_sent_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
txid: Mapped[str | None] = mapped_column(String(64), default=None)
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
class BugReport(Base):
__tablename__ = "bug_reports"
id: Mapped[int] = mapped_column(primary_key=True)
description: Mapped[str] = mapped_column(Text)
contact: Mapped[str | None] = mapped_column(String(256), default=None)
# Set when the reporter was logged in at submission time; the report page is
# reachable both logged-in and logged-out (like GET /rounds/current), so this
# stays nullable rather than requiring auth just to file a report.
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
# distinct step from "resolved" so a reporter checking their own status (only
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
# seen this" apart from "this has actually been fixed".
status: Mapped[str] = mapped_column(String(16), default="open")
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
event_type: Mapped[str] = mapped_column(String(32))
payload_json: Mapped[str] = mapped_column(Text)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
created_at: Mapped[datetime] = mapped_column(default=utcnow)