Two schema changes the fixes in the following commits build on (BUGS.md B-09, B-04): ix_rounds_single_active is a unique index over the constant expression (1), restricted to the active statuses, so the table holds any number of closed rounds and only ever one live one. Rounds never overlapping was previously enforced only by a read-then-insert in open_new_round_if_needed, which two concurrent callers can both pass — and a second stuck "open" row blocks every future round forever, since get_active_round matches on status. The migration doesn't create that index blind: an instance that already has two active rounds (the very bug) would fail mid-migration with an opaque IntegrityError, so it first closes the stale duplicates and keeps the newest — which is what get_active_round was already doing silently. Verified against a DB seeded with an 'open' plus a 'closing' round. pending_transactions.failure_reason is for the reconciler added next: when it gives up on a transaction, an operator needs to see whether it was dropped or rejected. The PendingTransaction docstring now also documents the full status lifecycle (building -> pending -> confirmed | failed), since "building" is new and load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
8.8 KiB
Python
182 lines
8.8 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import BigInteger, ForeignKey, Index, String, 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"
|
|
|
|
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)
|
|
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)
|
|
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(String)
|
|
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 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(String)
|
|
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)
|