from datetime import datetime, timezone from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint 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) class Round(Base): __tablename__ = "rounds" 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, minimum amount) 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) min_amount_sats: Mapped[int] = mapped_column(BigInteger, default=100_000_000) 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.""" __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") replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None) attempt_count: Mapped[int] = mapped_column(default=1) 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)