RoundConfig.min_amount_sats was an independently-configurable floor that could drift out of sync with bet_amount_sats for no real reason (deposits never had a server-side minimum anyway). Drop the field and enforce amount_sats >= config.bet_amount_sats directly in request_withdrawal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
147 lines
7.0 KiB
Python
147 lines
7.0 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.
|
|
|
|
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."""
|
|
|
|
__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)
|