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. round_duration is intentionally NOT here: it stays env-var-driven per spec. Don't move it here without an explicit decision to change that. """ __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) 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)