Package skeleton, pyproject/alembic config, env-driven settings (app/config.py), and the SQLAlchemy models + initial Alembic migration covering users, UTXO events, rounds/participants, round config, pending transactions, withdrawals and the audit log. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
132 lines
5.9 KiB
Python
132 lines
5.9 KiB
Python
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)
|