Files
plm-lottery/app/db/models.py
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00

326 lines
19 KiB
Python

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")
# B-80 (DAT-60, RND-35): this round's own pool address, m/84'/746'/0'/2/<id>. The id
# only exists once the row is flushed, so the row is inserted with "" and the real
# address is written in the same transaction (rounds/service.py:open_new_round_if_needed)
# — "" is never visible outside it, and every consumer refuses it. Rounds opened before
# B-80 hold the reserve address (m/84'/746'/0'/1/0), their old shared pool.
pool_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
# 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)
# B-79 (DAT-73): the value the payout's bump reserve output was created with (it is paid to
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
# `server_seed` stays NULL until the reveal, in the transaction that stores the winner (or
# at the close of a round without participants), so a copy of the database alone never
# tells who will win. `participants_hash`, `draw_height` and each participant's
# `draw_position` are written when the round enters "drawing" and never change — what a
# resumed draw (B-81) reads instead of recomputing. `draw_confirmations` is the number of
# blocks the chain must have from `draw_height` before its hash is read, copied from the
# config at open.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
# B-95 (DAT-61, RND-05): the rest of what governs this round, copied from round_config in the
# transaction that opens it and never changed — the cost of a bet, where the commission goes,
# and the cap on the pool (0 = none). Read live, a mid-round edit gave players of the same
# round different stakes and redirected a round's commission. The fee *rate* stays live: it
# belongs to a transaction, not to the rules of the round.
bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000, server_default="1000000000")
fee_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
participants_hash: Mapped[str | None] = mapped_column(String(64), default=None)
draw_height: Mapped[int | None] = mapped_column(default=None)
class RoundParticipant(Base):
__tablename__ = "round_participants"
__table_args__ = (
UniqueConstraint("round_id", "user_id"),
# B-73 (DAT-66): one draw position per participant; NULLs (not yet in an ordered
# list) do not collide.
Index("ux_round_participants_round_draw_position", "round_id", "draw_position", unique=True),
)
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))
# B-73 (DAT-63): which output of the bet transaction pays the round's pool address —
# together with bet_txid the outpoint the draw list publishes and the payout spends.
bet_vout: Mapped[int] = mapped_column(default=0, server_default="0")
# Informational only since B-73 (DAT-25): the draw order is by bet_txid, which anyone
# can check, not by this server-side timestamp.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-66): 0-based position in the ordered draw list, written once on entering
# "drawing" for every confirmed participant.
draw_position: Mapped[int | 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)
# B-73 (DAT-62): blocks the chain must have from a round's draw_height before the hash
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
# applies from the next round.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
# as a deposit (platform transactions keep 1, coinbase outputs 120).
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
# B-95 (DAT-27, DAT-62): cap on the sum of a round's bets, 0 = no cap; copied onto each round
# when it opens. Past it a bet is refused with `round_full`.
max_round_pool_sats: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0")
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)
# B-78 (DAT-69): why the lottery is paused — NULL exactly when `paused` is false.
# "operator" (POST /admin/pause) or an automatic reason the system wrote itself
# ("resurrected_tx" so far; "reorg" and "payout_blocked" belong to B-85 and B-82).
# An automatic reason is never overwritten by an operator pause, so the cause that
# needs a look is not lost; POST /admin/resume clears whichever it is.
paused_reason: Mapped[str | None] = mapped_column(String(16), default=None)
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)
# B-78 (DAT-72): when the row went to "failed". A failed row stays watched for
# FAILED_WATCH_DAYS from here: a transaction given up for dead can still confirm.
failed_at: Mapped[datetime | None] = mapped_column(default=None)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class PendingTransactionTxid(Base):
"""Every version a PendingTransaction has ever had (B-77, DAT-70, DAT-71): the first
one, each RBF replacement and each candidate written before a bump is broadcast.
The network may confirm *any* of them — the original while a bump was in flight, a
replacement the process crashed before recording — so the poller and the reconciler
look for every txid of a row, not only `current_txid`. Rows are never modified or
deleted (except with the `bet` row `_release_failed_bet` removes); `txid` is unique
over the whole table, so a txid belongs to exactly one platform transaction.
`raw_hex` is empty only for a `replaced_by_txid` reconstructed by the migration,
whose raw transaction is no longer available."""
__tablename__ = "pending_transaction_txids"
id: Mapped[int] = mapped_column(primary_key=True)
pending_transaction_id: Mapped[int] = mapped_column(ForeignKey("pending_transactions.id"), index=True)
txid: Mapped[str] = mapped_column(String(64), unique=True)
raw_hex: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=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)