reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.
- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
the history of the row's address and looks for every txid of the row. Present on any
server -> promoted (adopting the version found). Absent is only "no server of the
quorum knows it" and needs the active server plus the quorum minimum of the others to
have answered; even then nothing is released without proof:
(a) an input spent in a block by a transaction that is none of the row's versions,
agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
(b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
refused by all of them, explicitly, for missing/spent inputs.
Accepted or "already known" by any server means alive; a fee refusal is not proof
(`building` -> `pending` for the bumper, `pending` untouched); too few answers or
anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
a 14-day watch: a failed row that a majority of the other servers (at least the quorum
minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
from POST /admin/pause (which accepts an optional `note` and never overwrites an
automatic reason), cleared by resume, which logs the reason it lifted; exposed on
/admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
but never abandon.
Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
274 lines
15 KiB
Python
274 lines
15 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text
|
|
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"
|
|
|
|
# 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),)
|
|
|
|
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)
|
|
# 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")
|
|
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)
|
|
|
|
|
|
_ACTIVE_ROUND_STATUSES_SQL = "'open', 'closing', 'drawing', 'paying_out'"
|
|
|
|
|
|
class Round(Base):
|
|
__tablename__ = "rounds"
|
|
|
|
# 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})"),
|
|
),
|
|
)
|
|
|
|
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)
|
|
# 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")
|
|
# 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="")
|
|
# 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)
|
|
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)
|
|
# 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)
|
|
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
|
|
|
|
|
class PendingTransaction(Base):
|
|
"""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.
|
|
"""
|
|
|
|
__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(Text)
|
|
# 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.
|
|
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
|
# 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.
|
|
replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None)
|
|
attempt_count: Mapped[int] = mapped_column(default=1)
|
|
# 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)
|
|
# 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)
|
|
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
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(Text)
|
|
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)
|