round_duration_seconds was read live on every scheduler tick and every bet check, with the deadline computed as opened_at + duration. Lowering it from 600 to 60 while a round was 300s in closed that round instantly; raising it moved the closes_at clients were already counting down to. round_cooldown_seconds had the same property for the gap after a close. B-11 fixed this class of problem for the advertised jackpot; the timing fields were left live. Round now carries duration_seconds and cooldown_seconds, set from the config when it opens. round_deadline() is the single place the deadline is computed — the scheduler, place_bet's two checks and /rounds/current's closes_at all go through it — and the cooldown is read off the round that just closed, so the gap a round announced is the gap that's honoured. The config row becomes what the *next* round opens with. The migration backfills from the live config rather than leaving the column defaults: an instance running 300s rounds would otherwise see the round currently in progress jump to 600s the moment this lands, which is precisely the retroactive change being fixed. Verified against a scratch DB with a non-default config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
13 KiB
Python
238 lines
13 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")
|
|
# 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)
|