2026-07-21 10:25:23 +02:00
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
2026-07-27 16:24:05 +02:00
|
|
|
from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text
|
2026-07-21 10:25:23 +02:00
|
|
|
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"
|
|
|
|
|
|
2026-08-03 22:27:15 +02:00
|
|
|
# B-57: usernames are compared case-insensitively, and that has to be the
|
|
|
|
|
# database's job, not a convention the query layer remembers. "Bob" and "bob"
|
|
|
|
|
# used to be two accounts sharing one rate-limit bucket (each locking the other
|
|
|
|
|
# out) and, worse on a custodial system, a ready-made impersonation vector.
|
|
|
|
|
# A functional unique index rather than a normalized column: the name stays
|
|
|
|
|
# stored exactly as the user typed it, which is what /admin and the audit log
|
|
|
|
|
# display. The username pattern (auth/routes.py) is ASCII-only, so lower() is
|
|
|
|
|
# the whole of the normalization — no Unicode casefolding subtleties apply.
|
|
|
|
|
__table_args__ = (Index("ix_users_username_lower", text("lower(username)"), unique=True),)
|
|
|
|
|
|
2026-07-21 10:25:23 +02:00
|
|
|
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)
|
2026-07-27 12:02:23 +02:00
|
|
|
# Embedded in every issued JWT (app/auth/security.py) and checked on every
|
|
|
|
|
# request (app/auth/dependencies.py:get_current_user). Bumped on a
|
|
|
|
|
# self-service or admin password change so every token issued before that
|
|
|
|
|
# point stops working immediately, instead of staying valid for up to
|
|
|
|
|
# jwt_expire_minutes after a compromised account's password is reset (B-34).
|
|
|
|
|
token_version: Mapped[int] = mapped_column(default=0, server_default="0")
|
2026-07-21 10:25:23 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 00:30:48 +02:00
|
|
|
_ACTIVE_ROUND_STATUSES_SQL = "'open', 'closing', 'drawing', 'paying_out'"
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 10:25:23 +02:00
|
|
|
class Round(Base):
|
|
|
|
|
__tablename__ = "rounds"
|
|
|
|
|
|
2026-07-27 00:30:48 +02:00
|
|
|
# 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})"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-21 10:25:23 +02:00
|
|
|
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)
|
2026-08-03 23:25:56 +02:00
|
|
|
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
|
|
|
|
|
# Read live from the config, a mid-round edit applied retroactively: lowering
|
|
|
|
|
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
|
|
|
|
|
# instantly, and raising it moved the closes_at every client was already
|
|
|
|
|
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
|
|
|
|
|
# The config row is now what the *next* round opens with; these are what this
|
|
|
|
|
# round runs by. cooldown_seconds is read off the round that just closed, so
|
|
|
|
|
# the gap it announced is the gap that's honoured.
|
|
|
|
|
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
|
|
|
|
|
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
# B-80 (DAT-60, RND-35): this round's own pool address, m/84'/746'/0'/2/<id>. The id
|
|
|
|
|
# only exists once the row is flushed, so the row is inserted with "" and the real
|
|
|
|
|
# address is written in the same transaction (rounds/service.py:open_new_round_if_needed)
|
|
|
|
|
# — "" is never visible outside it, and every consumer refuses it. Rounds opened before
|
|
|
|
|
# B-80 hold the reserve address (m/84'/746'/0'/1/0), their old shared pool.
|
|
|
|
|
pool_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
|
2026-07-27 14:12:27 +02:00
|
|
|
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
|
|
|
|
|
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
|
|
|
|
|
# (draw_waiting_since) measure how long a round has been waiting on a block,
|
|
|
|
|
# since that wait has no timeout of its own — see _wait_for_next_block.
|
|
|
|
|
drawing_started_at: Mapped[datetime | None] = mapped_column(default=None)
|
2026-07-21 10:25:23 +02:00
|
|
|
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)
|
Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)
2026-10-16 13:55:51 +02:00
|
|
|
# B-79 (DAT-73): the value the payout's bump reserve output was created with (it is paid to
|
|
|
|
|
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
|
|
|
|
|
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
|
2026-07-21 10:25:23 +02:00
|
|
|
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
2026-10-16 14:25:35 +02:00
|
|
|
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
|
|
|
|
|
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
|
|
|
|
|
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
|
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
|
|
|
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
|
|
|
|
|
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
|
|
|
|
|
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
|
|
|
|
|
# `server_seed` stays NULL until the reveal, in the transaction that stores the winner (or
|
|
|
|
|
# at the close of a round without participants), so a copy of the database alone never
|
|
|
|
|
# tells who will win. `participants_hash`, `draw_height` and each participant's
|
|
|
|
|
# `draw_position` are written when the round enters "drawing" and never change — what a
|
|
|
|
|
# resumed draw (B-81) reads instead of recomputing. `draw_confirmations` is the number of
|
|
|
|
|
# blocks the chain must have from `draw_height` before its hash is read, copied from the
|
|
|
|
|
# config at open.
|
|
|
|
|
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
|
|
|
|
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
|
|
|
|
|
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
|
|
|
|
|
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
|
|
|
|
participants_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
|
|
|
|
draw_height: Mapped[int | None] = mapped_column(default=None)
|
2026-07-21 10:25:23 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class RoundParticipant(Base):
|
|
|
|
|
__tablename__ = "round_participants"
|
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint("round_id", "user_id"),
|
|
|
|
|
# B-73 (DAT-66): one draw position per participant; NULLs (not yet in an ordered
|
|
|
|
|
# list) do not collide.
|
|
|
|
|
Index("ux_round_participants_round_draw_position", "round_id", "draw_position", unique=True),
|
|
|
|
|
)
|
2026-07-21 10:25:23 +02:00
|
|
|
|
|
|
|
|
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))
|
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
|
|
|
# B-73 (DAT-63): which output of the bet transaction pays the round's pool address —
|
|
|
|
|
# together with bet_txid the outpoint the draw list publishes and the payout spends.
|
|
|
|
|
bet_vout: Mapped[int] = mapped_column(default=0, server_default="0")
|
|
|
|
|
# Informational only since B-73 (DAT-25): the draw order is by bet_txid, which anyone
|
|
|
|
|
# can check, not by this server-side timestamp.
|
2026-07-21 10:25:23 +02:00
|
|
|
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
|
|
|
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
|
|
|
# B-73 (DAT-66): 0-based position in the ordered draw list, written once on entering
|
|
|
|
|
# "drawing" for every confirmed participant.
|
|
|
|
|
draw_position: Mapped[int | None] = mapped_column(default=None)
|
2026-07-21 10:25:23 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-07-21 15:05:40 +02:00
|
|
|
Everything business/round-related lives here (round timing, bet amount, fee
|
2026-07-22 19:25:15 +02:00
|
|
|
rate, RBF timeout) so an operator can tune it live. Secrets
|
2026-07-21 15:05:40 +02:00
|
|
|
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."""
|
2026-07-21 10:25:23 +02:00
|
|
|
|
|
|
|
|
__tablename__ = "round_config"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
fee_address: Mapped[str] = mapped_column(String(128))
|
2026-07-21 15:05:40 +02:00
|
|
|
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)
|
2026-07-21 16:04:02 +02:00
|
|
|
# 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)
|
Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
2026-10-14 16:29:09 +02:00
|
|
|
# B-73 (DAT-62): blocks the chain must have from a round's draw_height before the hash
|
|
|
|
|
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
|
|
|
|
|
# applies from the next round.
|
|
|
|
|
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
2026-10-16 14:25:35 +02:00
|
|
|
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
|
|
|
|
|
# as a deposit (platform transactions keep 1, coinbase outputs 120).
|
|
|
|
|
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
|
2026-07-21 15:05:40 +02:00
|
|
|
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
|
|
|
|
|
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
2026-07-22 10:36:36 +02:00
|
|
|
# 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)
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
# B-78 (DAT-69): why the lottery is paused — NULL exactly when `paused` is false.
|
|
|
|
|
# "operator" (POST /admin/pause) or an automatic reason the system wrote itself
|
|
|
|
|
# ("resurrected_tx" so far; "reorg" and "payout_blocked" belong to B-85 and B-82).
|
|
|
|
|
# An automatic reason is never overwritten by an operator pause, so the cause that
|
|
|
|
|
# needs a look is not lost; POST /admin/resume clears whichever it is.
|
|
|
|
|
paused_reason: Mapped[str | None] = mapped_column(String(16), default=None)
|
2026-07-21 10:25:23 +02:00
|
|
|
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PendingTransaction(Base):
|
2026-07-27 00:30:48 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-07-21 10:25:23 +02:00
|
|
|
|
|
|
|
|
__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]
|
2026-07-27 16:24:05 +02:00
|
|
|
raw_tx_hex: Mapped[str] = mapped_column(Text)
|
2026-07-27 09:39:38 +02:00
|
|
|
# The *first* broadcast — never rewritten by a bump — since this is what the
|
|
|
|
|
# reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures
|
|
|
|
|
# from. Bumping used to overwrite this field, which reset that clock on every
|
|
|
|
|
# bump and meant a repeatedly-bumped-but-never-mined tx was never abandoned
|
|
|
|
|
# (B-27). last_broadcast_at is the one bump_fee updates, and the one should_bump
|
|
|
|
|
# (app/tx/broadcast.py) reads to decide whether another bump is due.
|
2026-07-21 10:25:23 +02:00
|
|
|
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
2026-07-27 09:39:38 +02:00
|
|
|
last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
2026-07-21 10:25:23 +02:00
|
|
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
2026-07-27 00:30:48 +02:00
|
|
|
# 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.
|
2026-07-21 10:25:23 +02:00
|
|
|
replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
|
|
|
|
attempt_count: Mapped[int] = mapped_column(default=1)
|
2026-07-27 00:30:48 +02:00
|
|
|
# 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)
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
# B-78 (DAT-72): when the row went to "failed". A failed row stays watched for
|
|
|
|
|
# FAILED_WATCH_DAYS from here: a transaction given up for dead can still confirm.
|
|
|
|
|
failed_at: Mapped[datetime | None] = mapped_column(default=None)
|
2026-07-21 10:25:23 +02:00
|
|
|
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
|
|
|
|
|
|
|
|
|
|
Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
2026-10-14 14:46:43 +02:00
|
|
|
class PendingTransactionTxid(Base):
|
|
|
|
|
"""Every version a PendingTransaction has ever had (B-77, DAT-70, DAT-71): the first
|
|
|
|
|
one, each RBF replacement and each candidate written before a bump is broadcast.
|
|
|
|
|
|
|
|
|
|
The network may confirm *any* of them — the original while a bump was in flight, a
|
|
|
|
|
replacement the process crashed before recording — so the poller and the reconciler
|
|
|
|
|
look for every txid of a row, not only `current_txid`. Rows are never modified or
|
|
|
|
|
deleted (except with the `bet` row `_release_failed_bet` removes); `txid` is unique
|
|
|
|
|
over the whole table, so a txid belongs to exactly one platform transaction.
|
|
|
|
|
`raw_hex` is empty only for a `replaced_by_txid` reconstructed by the migration,
|
|
|
|
|
whose raw transaction is no longer available."""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "pending_transaction_txids"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
pending_transaction_id: Mapped[int] = mapped_column(ForeignKey("pending_transactions.id"), index=True)
|
|
|
|
|
txid: Mapped[str] = mapped_column(String(64), unique=True)
|
|
|
|
|
raw_hex: Mapped[str] = mapped_column(Text)
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 10:25:23 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 15:50:40 +02:00
|
|
|
class BugReport(Base):
|
|
|
|
|
__tablename__ = "bug_reports"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
description: Mapped[str] = mapped_column(Text)
|
|
|
|
|
contact: Mapped[str | None] = mapped_column(String(256), default=None)
|
|
|
|
|
# Set when the reporter was logged in at submission time; the report page is
|
|
|
|
|
# reachable both logged-in and logged-out (like GET /rounds/current), so this
|
|
|
|
|
# stays nullable rather than requiring auth just to file a report.
|
|
|
|
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
|
|
|
|
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
|
|
|
|
|
# distinct step from "resolved" so a reporter checking their own status (only
|
|
|
|
|
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
|
|
|
|
|
# seen this" apart from "this has actually been fixed".
|
|
|
|
|
status: Mapped[str] = mapped_column(String(16), default="open")
|
|
|
|
|
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 10:25:23 +02:00
|
|
|
class AuditLog(Base):
|
|
|
|
|
__tablename__ = "audit_log"
|
|
|
|
|
|
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
|
|
|
event_type: Mapped[str] = mapped_column(String(32))
|
2026-07-27 16:24:05 +02:00
|
|
|
payload_json: Mapped[str] = mapped_column(Text)
|
2026-07-21 10:25:23 +02:00
|
|
|
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)
|