44 Commits
Author SHA1 Message Date
davide 5cfe2d6f95 Merge audit-2026-07-27: fix all 25 findings of the second audit
B-25 … B-49, each in its own commit with its own regression test — the suite
went from 139 to 253 tests. Also on this branch: Docker + Caddy security
headers, the API docs gate, admin endpoint limits, and the SSE gaps.
2026-07-27 23:37:12 +02:00
davide 0d2fef6502 Delete BUGS.md now that both audits' findings are closed
B-01 … B-49 are all fixed, so the file held no open work — only a history that
git already keeps. CLAUDE.md now explains how to resolve the B-nn markers left
throughout the code against that history, and repeats the caveat the empty list
does not carry on its own: no open findings is not the same as no bugs.

The one remaining reference, in an already-applied migration's docstring, is
left as the historical record it is.
2026-07-27 23:37:02 +02:00
davide e7f844b11f Publish an SSE update from the bet/withdrawal rollback paths (B-49)
_release_failed_bet and _release_failed_withdrawal restored the balance, freed
the reserved UTXOs and (for a bet) removed the participant without calling
broadcaster.publish(), so every dashboard kept showing the phantom bet and the
reduced balance until its next poll — while the success path and the
reconciler's own abandon path both published.

The two regression tests pre-open the round before subscribing: place_bet opens
one itself, and that publish() would otherwise satisfy the assertion whether or
not the rollback published anything.
2026-07-27 23:35:10 +02:00
davide 4c80c1c5bf Cap the number of inputs a transaction may spend (B-48)
select_utxos had no ceiling on input count, so an address fragmented into many
small deposits built an ever-larger transaction whose fee — deducted from the
amount being moved — eroded the bet's share of the pool or the withdrawn amount,
and past a few hundred inputs stopped being standard at all.

MAX_TX_INPUTS (50) now bounds the selection. Reaching the cap without covering
the target is reported as its own "too_many_inputs" code, distinct from having
no funds, with the cap carried in the error params for the 7 translations. The
payout path records the same distinction in its payout_failed audit reason.
2026-07-27 23:30:06 +02:00
davideandClaude Sonnet 5 6a90136b50 Widen raw_tx_hex and payload_json from String to Text (B-47)
Both held arbitrary-length data (a raw signed transaction hex, an audit
payload) in a bare String, which SQLAlchemy compiles to VARCHAR with no
length. SQLite and PostgreSQL accept that; other backends like MySQL
require a length on VARCHAR and would reject it. Add a migration
(verified upgrade/downgrade/upgrade round-trip, and confirmed with
`alembic check` that it leaves no further diff against the models).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:24:05 +02:00
davideandClaude Sonnet 5 31bc9a327f Compare admin token as UTF-8 bytes to avoid TypeError on non-ASCII input (B-46)
secrets.compare_digest raises TypeError instead of returning False when a
str argument contains non-ASCII characters, turning a bad admin token into
an unhandled 500 instead of the expected 403. Encode both sides before
comparing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:17:40 +02:00
davideandClaude Sonnet 5 6045c89ed0 Bound admin list endpoint limits, add status filter to pending-transactions (B-45)
/admin/rounds and /admin/audit-log accepted any limit, including -1 (which
SQLite treats as "no limit"), and /admin/pending-transactions had no limit
at all -- it grows without end. Add Query(default=..., ge=1, le=500) to all
three, plus an optional status filter on pending-transactions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:10:53 +02:00
davideandClaude Sonnet 5 a574db0d93 Align README and running-the-server.md with the Docker-only policy (B-44)
CLAUDE.md declares the server always runs via Docker (dev and prod
alike) with no supported bare-uvicorn workflow, but README's Quick
start and docs/running-the-server.md's "Locale / venv" section still
documented running uvicorn directly — a leftover from before that
policy was adopted. Rewrite both to a single Docker-only path and
update CLAUDE.md's own note about it.

Verified docker compose run --rm app python scripts/generate_master_key.py
against a real build/run to confirm the Quick start's Docker commands
actually work as documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:04:24 +02:00
davideandClaude Sonnet 5 d60da11603 Add baseline HTTP security headers in Caddy (B-43)
Caddy adds none of these on its own. Add HSTS, X-Content-Type-Options,
X-Frame-Options, Referrer-Policy and a CSP scoped to default-src
'self' plus the one external asset (Google Fonts). script-src/style-src
need 'unsafe-inline' because both SPAs rely on inline onclick handlers
and style="" attributes throughout — removing those is a separate,
larger refactor.

Validated with `caddy validate` and a live container curl check.
Adds a static regression test asserting the header directives stay
present in the Caddyfile, since nothing else in the Python suite
exercises it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:44:47 +02:00
davideandClaude Sonnet 5 22e3cfb2be Disable Swagger/ReDoc/OpenAPI JSON by default (B-42)
They enumerate the entire API surface, admin endpoints included, to
anyone who requests them. Gate them behind a new ENABLE_API_DOCS
setting (off by default) and update README/docs and BUGS.md/CLAUDE.md
open-bug counts accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:34:49 +02:00
davide 4124dc08e6 Check confirmation/existence via scripthash history, not verbose replies (B-41)
poll_once and reconcile.py's existence check both called
blockchain.transaction.get(txid, verbose=True). Several Electrum server
implementations and versions reject the verbose flag outright
("verbose transactions are currently unsupported"), which would have
meant no confirmations and no reconciliation ever running against such
a server, read as a plain transport error. reconcile.py additionally
decided whether to abandon a transaction - releasing its funds - by
substring-matching the error text ("missing", "not found", ...), which
only works against ElectrumX's specific wording.

Both now ask blockchain.scripthash.get_history for the address that
owns every input of the transaction (a user's own address for a
bet/withdrawal, the pool address for a payout) and look for the txid in
the result: present with height > 0 means confirmed, present with
height <= 0 means still in the mempool, absent means the server
doesn't know it. get_history is a plain, universally-supported Electrum
method, and "not in the list" replaces the old substring-matching
entirely - no more guessing at error wording to decide whether to
release funds. History is cached per scripthash within one pass, since
every "payout" row shares the same pool address.

New app/tx/pending_address.py factors out own_address_for (the
address derivation was previously duplicated informally inside
tx/broadcast.py's signing context) so confirmation.py and reconcile.py
share one definition instead of two that could compute different
addresses for the same row.

tests/unit/test_confirmation.py and test_reconcile.py needed real User
rows and a master-key bootstrap they didn't have before, since address
derivation is now exercised for real rather than assumed. Suite grows
from 217 to 222 tests. BUGS.md moves B-41 to Previously fixed - no
Medium-severity finding remains open.
2026-07-27 15:27:58 +02:00
davide 08c566d547 Restructure bump_fee into three phases, drop float fee math (B-40)
bump_fee issued one get_transaction per input (up to 15s each) and
then a broadcast, all with the caller's DB session held open - exactly
the pattern already fixed elsewhere for the same reason (B-18's
_trigger_payout, B-31's refresh_user). Also, _prevout_amount computed
a prevout's satoshi value via round(value_coins * 100_000_000) on a
float the server reported, in a codebase that is otherwise strictly
integer-satoshi.

bump_fee now takes a session_factory and a pending_id instead of a
live session and row, with three phases: read what's needed (the
signing key, current fee rate, raw tx) and close the session before
any network call; do the chain reads, signing and broadcast with no
session open; reopen a session only to persist the outcome.
_prevout_amount now asks for the raw (non-verbose) transaction and
reads embit's parsed TransactionOutput.value directly - already an
exact integer, no float conversion involved at all.

A pending_transaction that's no longer "pending" by the time bump_fee
actually runs (it confirmed in the meantime, a normal race) is now a
quiet no-op returning None, rather than being folded into RbfBumper's
error-logging path.

Suite grows from 214 to 217 tests. BUGS.md moves B-40 to Previously
fixed, and trims its own now-stale claim that bump_fee still depended
on verbose=True (B-41) - it no longer does.
2026-07-27 15:14:58 +02:00
davide 97545ad91f Run SQLite in WAL mode with a busy_timeout (B-39)
create_async_engine had no connect_args and there was no PRAGMA
anywhere in the repo. SQLite's default rollback-journal mode lets a
writer block every reader for the duration of its transaction, and a
second writer arriving while one is already active fails immediately
with "database is locked" rather than waiting at all - realistic given
five concurrent background tasks (scheduler, confirmation poller, RBF
bumper, two reconcilers) plus every HTTP handler share one file, and
nothing previously handled that error.

app/db/base.py now registers a "connect" event on the engine that sets
journal_mode=WAL, synchronous=NORMAL and a 5-second busy_timeout on
every new DBAPI connection - applied only when the dialect is sqlite,
so a future PostgreSQL DATABASE_URL is unaffected. WAL lets readers and
writers proceed without blocking each other, and busy_timeout gives a
second writer a real window to wait instead of failing instantly.

Left out: an explicit application-level retry wrapper for "database is
locked" in the background loops, the other half of the proposed fix -
busy_timeout already gives SQLite itself several seconds to resolve
writer-vs-writer contention before ever raising, and every background
loop already catches and logs an unhandled exception before its next
scheduled tick, which is itself a retry, just not an immediate one.

Suite grows from 211 to 214 tests. BUGS.md moves B-39 to Previously
fixed.
2026-07-27 14:53:22 +02:00
davide 702b37b319 Cap SSE subscribers per client IP instead of only globally (B-38)
GET /rounds/stream capped concurrent subscribers with one global
counter (MAX_SUBSCRIBERS=500): anyone opening 500 connections degraded
every other user to polling. The comment called it a defensive cap;
it was actually the vector, since nothing stopped a single source from
exhausting it alone.

RoundEventBroadcaster now also tracks subscribers per client IP,
capped much lower (MAX_SUBSCRIBERS_PER_IP=5). Past that cap, opening
one more stream evicts that same IP's own oldest connection (woken via
a new EVICTED sentinel so the SSE generator closes it promptly) rather
than refusing the new one or letting one abusive IP crowd out unrelated
clients under the old global-only cap. The global cap stays as a
backstop against overall resource exhaustion regardless of source.

Extracted client_ip() (X-Forwarded-For, since Caddy reverse-proxies
every request) out of auth/routes.py into app/api/client_ip.py so the
login/registration throttles (B-33) and this new per-IP cap share one
definition instead of two that could drift apart.

Not implemented: enforcing the connection cap at the Caddy layer
itself, which the proposed fix also suggested - the standard Caddy
image this project uses has no such directive without a third-party
module, and building a custom image felt like a bigger, separate change
than this fix warranted.

Suite grows from 201 to 211 tests. BUGS.md moves B-38 to Previously
fixed.
2026-07-27 14:44:15 +02:00
davideandClaude Sonnet 5 0b44fe632e Distinguish a pending-only balance from a truly insufficient one (B-37)
request_withdrawal validated against confirmed UTXOs only and answered
a flat insufficient_balance even when the requested amount was covered
by the pending-inclusive balance the UI actually shows (unconfirmed
change from a recent bet/withdrawal) — contradicting what the user was
looking at on screen. Raise balance_pending_confirmation instead when
compute_pending_balance covers the amount, carrying the pending sats
in params, with its error.* string in all 7 languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:23:00 +02:00
davideandClaude Sonnet 5 7fa26df104 Make a stalled draw wait observable (B-36)
_wait_for_next_block had no timeout, no log, and no audit entry: a
connection that stopped advancing the tip left a round silently frozen
in "drawing" with nothing in /admin to explain why. Log progress
periodically, write a draw_stalled audit entry past a threshold (a few
block-time multiples), and surface the wait via a new Round.drawing_started_at
column, exposed as draw_waiting_since in GET /rounds/current.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:12:27 +02:00
davideandClaude Sonnet 5 bb8b71278a Stamp UTC on naive API timestamps before serializing (B-35)
SQLite/aiosqlite returns DateTime columns as naive even though every
value is written in UTC, so a bare .isoformat() dropped the offset and
the frontend's new Date() parsed it as local time. Add a shared
isoformat_utc() helper and use it at every call site that was missing
the fix already applied ad hoc in rounds.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 12:20:22 +02:00
davideandClaude Sonnet 5 739fc9fed2 Invalidate existing sessions on password change/reset (B-34)
Neither self-service password change nor the admin reset invalidated
already-issued JWTs — a 24h-lifetime token stayed valid regardless, so
a stolen token (or an attacker who already had the old password) kept
working past a password change meant to lock them out. The admin reset
exists precisely for the "account compromised" case and didn't evict
the attacker at all.

Add User.token_version (migration 943dbd74d983), embedded in every JWT
as a "tv" claim and checked against the DB on every request in
get_current_user/get_optional_user; a mismatch reads as session_expired.
Both change-password and the admin reset bump it. change-password hands
back a freshly minted token so the caller's own session keeps working
instead of being logged out by its own request; the admin reset does
not, since that session isn't the one making the call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 12:02:23 +02:00
davideandClaude Sonnet 5 16802cafb6 Throttle login and registration with exponential backoff (B-33)
POST /auth/login had no rate limiting, no lockout, no delay — a patient
distributed attack could brute-force a password against an enumerable
username list on a custodial wallet, where a guessed password means
withdrawing someone's funds.

Add per-username and per-IP throttling with exponential backoff
(app/auth/rate_limit.py), keyed on app.state like UserLocks rather than
a module global so each app instance gets isolated throttle state.
Unknown-user and wrong-password already shared one response path, so no
enumeration oracle there. Registration is throttled per-IP too, which
also bounds how many accounts one IP can spin up (B-31).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 11:51:26 +02:00
davide 17c557b8a3 Meet BIP125's relay minimum on every RBF bump, and cap the fee rate (B-32)
bump_fee computed fee_delta as new_fee - old_fee, falling back to a
flat 1-satoshi bump whenever that came out zero or negative - which
happened whenever old_fee (the actual fee paid, from real prevout
amounts) already exceeded the naive target, e.g. because dust change
had been folded into the original fee (psbt_builder.py's
DUST_LIMIT_SATS handling). A 1-satoshi total increase is nowhere near
BIP125 rule 4's minimum (the replacement must pay at least the
incremental relay fee rate times its own vsize more than what it
replaces), so the node rejected it every time - and since bump_fee
raised before touching `pending`, the next tick retried with identical
parameters every 30 seconds, forever. Separately, the fee rate climbed
by 1 sat/vB every bump with no ceiling.

fee_delta is now max(target_fee - old_fee, vsize * the incremental
relay rate) - always at least the relay-mandated minimum regardless of
what the naive arithmetic produces. pending.fee_rate_sat_vb is set to
the actual resulting rate rather than the naive target, so a later
bump's arithmetic starts from what's really being paid instead of
drifting from it. Once a transaction reaches MAX_FEE_RATE_SAT_VB (a
new constant, 10,000 sat/vB, shared with RoundConfig.fee_rate_sat_vb's
existing admin-facing bound so the two can't drift apart - the same
reason MIN_PASSWORD_LENGTH is shared elsewhere) bump_fee refuses to
bump further; the reconciler abandons it if it never confirms (B-27)
instead of this retrying forever.

Suite grows from 185 to 187 tests. BUGS.md moves B-32 to Previously
fixed.
2026-07-27 11:16:20 +02:00
davide fe5639a037 Update CLAUDE.md 2026-07-27 11:05:53 +02:00
davide 12df04178e Resubscribe concurrently and in the background on reconnect (B-31)
_run_once awaited _subscribe_all_users() inline, before starting the
header/scripthash consumer tasks, and that method subscribed one user
at a time. At thousands of users that's thousands of sequential
round-trips during which nothing else ran: tip_height was frozen and
an in-flight draw's _wait_for_next_block made zero progress for the
entire resubscribe - a reconnect (which the listener already treats as
routine, not exceptional) could stall the lottery for minutes.

_subscribe_all_users now fans out with bounded concurrency
(asyncio.Semaphore, 20 at a time) instead of a serial loop, and one
user's failure no longer stops the rest. _run_once now starts it as
its own background task, created after the consumer tasks rather than
awaited before them, so tip updates and already-subscribed users'
notifications keep flowing throughout - its own completion is
deliberately not raced against the session-ending tasks (unlike them,
it's expected to finish normally), and its failure is logged the same
way address_for_new_user's background task is (B-30).

Left out: decoupling the listunspent refresh from the subscribe call
itself (the third part of the proposed fix) - the periodic
DepositReconciler (B-30) already provides a backstop for a slow or
delayed initial refresh, so the added complexity wasn't worth it here.

Suite grows from 182 to 185 tests, including an end-to-end test
against _run_once proving a new tip is processed while a slow
resubscribe is still in flight. BUGS.md moves B-31 to Previously
fixed.
2026-07-27 10:44:23 +02:00
davide 63df38d30b Add a periodic deposit reconciler, and stop losing subscribe tasks (B-30)
Deposits were credited exclusively by scripthash-change notifications,
with nothing re-verifying a user's balance against the chain if a
subscription was ever silently lost. address_for_new_user's subscribe
was fire-and-forget: the task wasn't retained, so it could be
garbage-collected mid-flight, and any failure (including self.client
turning None between the check and the task running) vanished into
asyncio's default unretrieved-exception handler instead of being
logged anywhere. On an otherwise healthy, long-lived connection there
may be no reconnect for days to re-subscribe everyone, so a user in
that state never saw their deposits.

address_for_new_user now retains the task and logs its exception if
it fails. New app/deposits/reconcile.py adds DepositReconciler, a
periodic sweep (every 5 minutes, gated on the Electrum client being
connected, same shape as tx/reconcile.py) that round-robins over every
user and calls the listener's own refresh_user (renamed from
_refresh_user since it's now called from outside the class) - so the
notification-driven and periodic paths can never behave differently.
Deliberately sweeps every user rather than only ones missing from the
in-memory scripthash map, since that map can't tell "never subscribed"
apart from "subscribed, but the server stopped delivering
notifications for it". Wired into app/main.py's lifespan alongside the
other three background reconcilers.

Suite grows from 176 to 182 tests. BUGS.md moves B-30 to Previously
fixed.
2026-07-27 10:35:23 +02:00
davide e8fdea0389 Corroborate an external spend before marking a UTXO gone (B-29)
detect_external_spends marked a UTXO spent_txid='external-spend'
irreversibly the moment it was missing from one listunspent reply, on
one server, with no way to undo it. A rotated-to server that's broken
or behind, or an empty reply, could zero a user's balance permanently.

Split into three functions in deposits/service.py: find_utxos_missing_
from (read-only candidate detection, and refuses to flag anything at
all when listunspent comes back entirely empty for a funded address -
that reads as a broken response, not a full sweep), mark_utxos_spent_
externally (persistence only, once a candidate is already confirmed),
and reinstate_reappeared_utxos (undoes the mark if the outpoint
reappears as unspent later).

ElectrumListener gains corroborate_utxo_spent, sharing the same
majority-quorum logic corroborate_header already uses for B-28: before
a candidate is marked, the other configured servers are asked whether
they also see it as spent. _refresh_user now reads candidates, then
corroborates each one with no DB session held open across those
network calls (same shape as B-18/B-25), then persists.

Suite grows from 165 to 176 tests. BUGS.md moves B-29 to Previously
fixed.
2026-07-27 10:25:07 +02:00
davide 0ce0562fd7 Validate Electrum headers and corroborate the draw's block (B-28)
A single hostile Electrum server, or a MITM on the one active
connection, could fabricate the block header the draw's entropy comes
from and so pick the winner of every round: headers were accepted with
no proof-of-work check and no link to the previous tip.

app/rounds/draw.py adds header_meets_its_own_target (rejects a header
whose hash doesn't satisfy the difficulty target it claims) and
header_prev_hash. electrum/listener.py's _apply_header now rejects a
header failing either check by raising HeaderValidationError, which
ends the session the same way a dropped connection would so the
listener rotates to the next configured server.

ElectrumListener gains corroborate_header: before the draw uses a
block, it's independently checked against the other configured servers
and needs a majority to agree. rounds/scheduler.py's
_wait_for_next_block now calls this and, on failure, logs why and
waits for a further block instead of ever using an uncorroborated
header.

Certificate/hostname verification stays disabled, so this doesn't
cover an attacker able to MITM every configured server at once -
BUGS.md notes that as not covered.

Suite grows from 151 to 165 tests. BUGS.md moves B-28 to Previously
fixed.
2026-07-27 10:07:21 +02:00
davide 7224ca0e66 Shorten the fixed B-25/26/27 entries in BUGS.md
Same as before: once a bug is fixed, its long write-up collapses into
a short paragraph pointing at the fix commits instead of repeating
what the code and commit messages already say. File goes from 442 to
343 lines.
2026-07-27 09:42:24 +02:00
davideandClaude Sonnet 5 933760e948 Decouple the RBF abandon clock from the bump clock (B-27)
bump_fee (tx/broadcast.py) used to overwrite PendingTransaction.
broadcast_at on every fee bump, but reconcile.py's abandon-after-N-
hours grace period is measured from that same column. A transaction
successfully bumped every rbf_timeout_seconds (900s by default) but
never mined reset that clock before it could ever reach the 6-hour
abandon window, so it was never abandoned: its UTXOs never returned to
the user, and if it was a bet the round stayed in "closing"
indefinitely.

PendingTransaction gains a last_broadcast_at column (migration
861e76aaf34c, backfilled from broadcast_at for existing rows before
the NOT NULL constraint is applied). broadcast_at is now never
rewritten after creation, so reconcile.py's _is_due keeps measuring
from the first broadcast unchanged. bump_fee updates last_broadcast_at
instead, and should_bump now reads last_broadcast_at rather than
broadcast_at — correct, since whether another bump is due should reset
after every bump, unlike the reconciler's abandon check, which must
not.

BUGS.md moves B-27 to "Previously fixed" with the fix description; the
suite grows from 148 to 151 tests, including a direct proof that a tx
bumped a minute ago but first broadcast 7 hours ago still gets
abandoned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:39:38 +02:00
davideandClaude Sonnet 5 50a43ae3ca Retry a stuck payout automatically, and log every failure (B-26)
_trigger_payout used to run exactly once, from _close_and_draw. Any
failure after that point — no Electrum client, insufficient pool
UTXOs, a missing fee_address, a rejected broadcast — wedged the round
in paying_out forever, and every one of those early returns except the
generic exception handler logged nothing at all: /admin showed a
stalled round with no explanation. A process restart while paying_out
hit the same dead end.

_tick now handles status == "paying_out": it calls the new
_retry_payout_if_due, which re-invokes _trigger_payout unless the most
recent payout_failed audit entry for the round is younger than
_PAYOUT_RETRY_INTERVAL_SECONDS (60s) — throttled so a persistently
broken payout (e.g. no fee_address set yet) doesn't retry, and re-log
a failure, on every 5-second tick. Every early return in
_trigger_payout now calls _log_payout_failure with a reason string, so
that throttle always has something to check against and /admin always
shows why a round is stuck.

This is safe to fire on a restart too, because B-25 already made
_trigger_payout idempotent (it no-ops if a non-terminal payout
PendingTransaction already exists) and persists before broadcasting —
so a round found paying_out at startup, whatever state its payout was
actually in, gets retried the same way. That closes the paying_out
half of the "scheduler doesn't resume mid-flight rounds after a
restart" gap in CLAUDE.md; the drawing/block-wait half is untouched
(see BUGS.md B-36).

BUGS.md moves B-26 to "Previously fixed" with the fix description; the
suite grows from 143 to 148 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:29:14 +02:00
davideandClaude Sonnet 5 f13f6850b7 Persist the payout before broadcasting it (B-25)
_trigger_payout used to broadcast the payout transaction and only
afterwards write payout_txid and its PendingTransaction. A crash in
that window (docker-compose.yml auto-restarts on crash) left a payout
on-chain with zero record: the round stuck in paying_out, nothing for
the reconciler to resolve, and a manual retry that would have paid the
winner a second time. This mirrors B-08, which already fixed the same
gap for place_bet/request_withdrawal.

_trigger_payout now has four phases: read, build (network read only,
no write), persist the intent as a PendingTransaction(kind="payout",
status="building") and commit, then broadcast and promote to
"pending". A rejected broadcast now leaves that "building" row for
tx/reconcile.py to resolve — its existing building/pending handling
already covers a payout kind correctly, including clearing
payout_txid on abandonment, so reconcile.py needed no changes.

Since pool UTXOs aren't tracked in utxo_events and so can never be
reserved/released the way a user's own UTXOs are, two guards go along
with the two-phase write: _trigger_payout now refuses to build a
second payout for a round that already has a non-terminal
PendingTransaction, and the payout builder excludes any UTXO already
referenced by any non-terminal payout transaction
(_reserved_payout_outpoints) so a stale payout from an earlier round
the reconciler hasn't abandoned yet can't be double-spent by a fresh
attempt.

This makes a payout retry safe; making one happen automatically is
B-26, still open. BUGS.md moves B-25 to "Previously fixed" with the
fix description; the suite grows from 139 to 143 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:19:53 +02:00
davideandClaude Opus 5 43d2321e0f Record the 25 open findings of the 2026-07-27 audit
A second full-codebase pass over app/, both static frontends and the
Docker/Caddy deployment found 25 issues (4 critical, 6 high, 7 medium,
8 low), none of which the 139-test suite catches. All are open.

Each entry carries file:line references, why it is a problem, and a
Proposed fix paragraph with the concrete approach rather than a bare
"fix this". The 24 findings of the 2026-07-26 audit move to a
"Previously fixed" section, unchanged.

The four critical ones:

  B-25  the payout broadcasts before recording anything, so a crash in
        that window leaves an on-chain payout with no DB trace and a
        manual retry would double-pay the winner
  B-26  any transient failure at payout time (no Electrum client,
        insufficient pool UTXOs) returns silently and wedges the round
        in paying_out forever, with nothing in the audit log
  B-27  every RBF bump rewrites broadcast_at, which is the same field
        the reconciler's 6-hour abandon deadline is measured from, so
        a repeatedly-bumped tx is never abandoned
  B-28  headers are accepted with no PoW or prev-hash validation over a
        TLS connection with certificate verification disabled, and that
        header is the draw's only source of entropy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:02:34 +02:00
davideandClaude Sonnet 5 f1a1145cda Detect UTXOs spent outside the platform and correct the cached balance
credit_confirmed_utxos only ever credited new UTXOs; a UTXO spent by
something other than the app's own bet/withdrawal/payout flow (e.g. someone
using the raw derived privkey directly) never got its spent_txid set, so
cached_balance_sats kept counting it forever. detect_external_spends mirrors
the same listunspent refresh in the other direction: anything still marked
unspent in our DB but missing from the address's current unspent set gets
spent_txid="external-spend", an audit_log entry, and an immediate balance
recompute — wired into the same ElectrumListener._refresh_user call that
already runs on every scripthash notification and on listener (re)connect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 08:45:09 +02:00
davideandClaude Sonnet 5 447bbba83e Add placeholder guide/bug-report pages, and an exclamation-mark bug icon
/guida now serves a static app/static/guida.html placeholder instead of
docs/guida-utente.md, which sidesteps the known Docker gap (docs/ was never
COPYed into the image). Added a matching /report-bug placeholder route and
pointed the navbar's bug icon at it instead of the unset GitHub issues URL,
with an outline circle + exclamation mark to match the help icon's style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 08:35:47 +02:00
davide f9f822f437 Document real fallback Electrum servers in .env.example
Use PalladiumWallet's mainnet bootstrap server list (ChainProfiles.cs)
as the example instead of a generic placeholder, since those are the
actual servers this deployment should fall back to.
2026-07-27 08:21:12 +02:00
davideandClaude Opus 5 845ba98409 Record the audit outcome and the architecture it changed
BUGS.md keeps every finding's original description and gains, per entry, what was
actually done and where its regression test lives — including the two entries
fixed differently from the plan (B-15 validates at startup, B-09 kept both
callers plus a bounded retry) and the one only partially fixed by decision (B-16,
where shipping the guide was deferred).

It also gains a Runtime verification section, which is the part worth reading:
what the live Docker deployment actually demonstrated (startup validation on the
real .env, the listener connecting and holding, rounds cycling, the migration
applied, and the reconciler's missing-tx heuristic checked against the real
server's error message) separated from what has no runtime evidence at all —
nothing has spent money since the restart, so the two-phase write, the RBF
retargeting, the reconciler's actual behaviour and the dust path are unit-tested
only. A green suite is not a working deployment, and the file now says so.

CLAUDE.md documents the two things a reader would otherwise have to reverse-
engineer: the transaction lifecycle (why rows are written before broadcasting,
what each PendingTransaction status means, why spent_txid must track the current
txid, and that one-active-round is now a DB invariant) and the Electrum
connection's rotation/keepalive/timeout behaviour. Its Known gaps list is rewritten
to say what is still open now that transaction-level state self-heals but
round-level state does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:35:28 +02:00
davideandClaude Opus 5 dc4d5761df Format amounts, guard the loading state, and translate the new errors
Amounts were rendered by bare sats/SATS_PER_PLM division, so binary
floating-point artefacts reached the UI — a 0.7 PLM jackpot could display as
0.7000000000000001 (B-22). formatPlm() in app.js and fmtPlm() in admin.js route
every display site through Intl.NumberFormat with the already-resolved language.
Input fields deliberately keep the raw value: a grouped, localized string would
break parseFloat, and amounts sent to the server still go through
Math.round(x * SATS_PER_PLM).

withLoading kept a snapshot of the button's markup and restored it in finally,
but refreshMe() is fired from the SSE handler, the poll chain, placeBet, withdraw
and showDashboard, all sharing #refresh-btn. Two overlapping calls made the second
snapshot the *loading* label and then restore it permanently, leaving the button
stuck on "Aggiornamento…" (B-23). The in-flight promise now lives in a WeakMap
keyed by the button, so a nested call awaits the existing one and only the
outermost call touches the markup.

The registration form mirrors the constraints the server now enforces
(minlength/pattern/required) and register() pre-checks the password length, so the
failure is immediate and translated instead of a generic 422 (B-12).

Five new error codes are translated in all 7 languages — broadcast_failed,
amount_below_dust_limit, withdrawal_to_own_address, internal_error,
guide_unavailable — keeping the key sets identical, as the i18n contract in
CLAUDE.md requires (verified: 123 keys per language).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:35:14 +02:00
davideandClaude Opus 5 7c4e9983ea Survive a dropped Electrum connection, and fall back to other servers
A dropped connection used to hang the whole platform permanently, and three
defects composed to do it (BUGS.md B-01):

The read loop's death was invisible. When the socket closed, _read_loop broke out
and finished, but _run_once was blocked on gather() over two notification
consumers waiting on queues nobody would ever fill again — it never returned and
never raised, so the reconnect-with-backoff logic was unreachable.
client.wait_closed() now resolves when the loop ends for any reason, and
_run_once races it against the consumers and a keepalive with
asyncio.wait(FIRST_COMPLETED).

Nothing had a timeout. request() registered a future, wrote to a half-closed
socket (drain() often doesn't raise) and awaited a reply that would never come.
That hung a POST /bets *while holding the per-user lock*, and could stop the
confirmation poller for good. Every request is now bounded at 15s, and a timeout
tears the connection down rather than leaving a server that owes us a reply in
rotation.

There was no keepalive, so on a quiet instance the normal way this connection
dies is an idle-timeout drop by the server (~10 minutes for many). A server.ping
every 60s makes that observable within a minute.

listener.client is also cleared before reconnecting, so callers stop treating a
dead connection as live.

On top of the finding, the listener now rotates over a list of servers:
ELECTRUM_FALLBACK_SERVERS holds comma-separated host:port[:notls] extras, tried
after the primary. Everything the platform does goes through this one connection
— deposit credits, broadcasts, confirmations, the chain tip the draw waits on —
which made a single hardcoded server its biggest point of failure. A failed or
dropped session moves to the next server immediately and only sleeps on the
backoff once every server has had a turn, so one dead server costs one attempt
instead of an outage, while a genuinely offline network still backs off. A
malformed entry fails at startup, not during the outage when the fallback is what
you need.

Also fixes B-19: header handling refuses a height below the current tip and
applies height and hex together, since _wait_for_next_block waits for
tip_height > tip_at_close (a regression silently added a block to the draw's
wait) and that hex is the draw's entropy source, so a mismatched pair would be
worse than a stale one.

Verified in the live deployment: the log shows the endpoint list, then "Electrum
connected to santantonio.sytes.net:50002", and the connection holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:34:59 +02:00
davideandClaude Opus 5 25f4a1c6b6 Refuse to start half-configured, and keep failures machine-readable
Startup validation (B-15). An empty JWT_SECRET makes PyJWT raise InvalidKeyError
on every login and an empty XPRV_ENCRYPTION_KEY makes Fernet fail on the first
key derivation — either way the container came up looking healthy and broke the
moment a real user touched it. validate_runtime_secrets() reports every problem
at once and is called from the lifespan (wired in the next commit).

Deviation from the plan in BUGS.md, which proposed a Pydantic field_validator:
Settings is constructed at import time by every module that reads config,
including the test suite, which has no .env and no business holding real secrets
— a validator there would fail a fresh clone at collection. At startup the
guarantee that matters is unchanged (the server won't serve traffic
half-configured) without coupling imports to a gitignored file. An empty
ADMIN_TOKEN is deliberately non-fatal: require_admin already denies everything,
so the effect is a locked panel, not an open one.

Unhandled errors answer the documented shape (B-24). The catch-all returned a
bare-string `detail` while app/api/errors.py documents
{"code", "message", "params"}, leaving clients to special-case exactly the
responses they understand least. It now returns internal_error in that shape,
with the exception text staying in logs/app.log and out of the response body.

GET /guida no longer crashes (B-16). It reads docs/guida-utente.md, which the
Dockerfile doesn't ship, so in every real deployment that navbar link was a 500 —
confirmed in the deployed log, which holds two of them from earlier today ending
in "RuntimeError: File at path docs/guida-utente.md does not exist." It now
checks the file and answers a structured 404 (guide_unavailable) with an error
logged. Shipping docs/ in the image was written and then reverted on request: the
guide is being reworked first, so /guida answers 404 in Docker for now, which is
an accepted state rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:34:33 +02:00
davideandClaude Opus 5 85dce221c5 Validate admin config and registration input, and log config changes
fee_address was the dangerous one (B-05). PUT /admin/config assigned whatever it
was given, and a well-formed address from another chain (bc1...) parses fine as a
witness program — so every round's 30% commission would be signed and broadcast
to a script nobody holds the key for. A malformed one instead wedged the payout
with an unhandled EmbitError. It now has to pass is_valid_plm_address, the same
check user withdrawals already had. Numeric fields got bounds too:
fee_rate_sat_vb=0 produces transactions no node relays, which stalls bets,
payouts and withdrawals alike, and round_duration_seconds=0 expires a round the
instant it opens.

Config changes are audit-logged (B-10). /pause and /resume were logged but a
config edit wasn't, so the most sensitive setting in the system could be changed
without leaving any trace — contradicting CLAUDE.md, which says audit_log records
what changed. The entry carries a before/after diff per field, computed before
assignment, and no-op updates write nothing. `paused` was removed from
_CONFIG_FIELDS so the maintenance switch has exactly one audited path; it stays
in the response model.

Admin token comparison is constant-time (B-14), with the empty-token check kept
*ahead* of it: compare_digest("", "") returns True, so the obvious ordering would
have opened the panel on any instance without an ADMIN_TOKEN.

Registration input (B-12). It accepted an empty username and a one-character
password while /users/me/change-password demanded 8 — an odd place to be lenient
on a custodial system holding real funds. MIN_PASSWORD_LENGTH moved to
auth/security.py so both share it, and the username is constrained to 3-32 chars
of [A-Za-z0-9_.-]. The IntegrityError handler also distinguishes a username
collision (answers username_taken) from a derivation-index one (retries): a
concurrent duplicate username used to be retried five times and then reported as
derivation_index_conflict, which told the user the wrong thing.

verify_password (B-13) catches VerificationError and InvalidHashError, not just
VerifyMismatchError, so an unparseable stored hash reads as "wrong password"
instead of a 500 — logged as an error, since that one is a data problem.

guida-admin.md gains a table of the audit events worth watching, including
payout_failed, which needs manual intervention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:32:48 +02:00
davideandClaude Opus 5 daf66fd6bc Fix the round-open race, the advertised jackpot, and payout error handling
Round opening (B-09). open_new_round_if_needed now handles the IntegrityError
from ix_rounds_single_active (previous commit) by rolling back and using the
winner's round. Deviation from the plan in BUGS.md, which proposed making the
scheduler the only writer: that would mean the first bet after a cooldown
couldn't open a round, so both callers stay and a bounded retry was added
instead — a conflict where nothing is active yet just means the winner hadn't
committed, and a bet must not fail on that timing. get_active_round also logs
loudly if it ever sees more than one active round rather than silently picking
the newest.

The jackpot (B-11). It was participant_count * the *current* bet_amount_sats,
which overstated the pool (each stored bet is already net of that bet's network
fee) and silently rewrote the advertised jackpot of a round in progress whenever
an operator edited the bet amount. It now sums the participants' stored
bet_amount_sats. The remaining imprecision — the payout tx's own fee, deducted
from the winner's share and unknowable until the payout is built — is documented
in the code rather than promised away, since the comment there claimed exactness.

Payout (B-05, B-18). _trigger_payout is split into read / build+broadcast /
persist, so no DB session is held across a network call (on SQLite that meant
holding the write lock for two unbounded round-trips). That restructuring is also
what makes the error handling placeable: it now catches Exception around the
chain work and writes a payout_failed audit entry, where a malformed fee_address
used to raise EmbitError all the way to the scheduler's catch-all, leaving the
round stuck in paying_out with nothing recorded about why. Automatic payout retry
remains an open gap.

The scheduler also counts "building" participants as in-flight when deciding
whether a round may close, matching the two-phase bet write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:32:22 +02:00
davideandClaude Opus 5 b4d70385a6 Leave dust-sized change to the fee instead of creating it
`if change > 0` created a change output for any leftover at all. Below the
P2WPKH dust threshold (294 sat: the output's 31 vbytes plus the 67 needed to
spend it, at the 3000 sat/kvB dust relay fee) relaying nodes reject the whole
transaction, so the bet or withdrawal failed at broadcast with an error the user
could do nothing about — and which arrived as a 500 (BUGS.md B-06).

Sub-dust change now goes to the fee in both builders, and a sub-dust
recipient/winner/commission amount is refused up front with its own error code.
The fee estimate already assumed two outputs, so dropping one never underpays.

Cross-checked against PalladiumWallet, the source of truth for PLM parameters:
it delegates to NBitcoin's TransactionBuilder (same 294 sat threshold) and has
an explicit test — Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee —
asserting the same behaviour, so both the value and the semantics match the
reference implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:31:41 +02:00
davideandClaude Opus 5 d528c5b475 Let the system recover from a broadcast that never confirms
The code treated a broadcast as final: money moved on-chain and the DB was
updated on the assumption it would either confirm or be fee-bumped until it
did. Neither is guaranteed, and every way that assumption broke was permanent
(BUGS.md B-02, B-03, B-04, B-07, B-08, B-20, B-21).

Persist before broadcasting. place_bet and request_withdrawal now write their
rows in a "building" state and commit, then broadcast, then promote to
broadcast/pending in a second commit. Before, a failure or crash between the
broadcast and the commit left the coins irreversibly spent with no trace: no
participant (so no entry in the draw), no pending row (so no RBF and no
confirmation tracking), and the UTXOs not even marked spent, so the next bet
would try to double-spend them. A refused broadcast now releases the reserved
UTXOs, restores the balance, removes the participant (or marks the withdrawal
failed), audit-logs it, and answers a translatable broadcast_failed — as 502,
since the network refused it, not the caller, where it used to be an opaque 500.

Reconcile what's in flight against the chain. New PendingTransactionReconciler
(app/tx/reconcile.py, every 120s and once at startup) asks whether each
non-terminal tx exists: present -> promote, gone -> mark failed with a reason,
release the inputs, roll the domain row back, audit-log it. Grace periods differ
by state (120s for "building", 6h for "pending", so the RBF bumper gets its
attempts first). It is deliberately biased to inaction: only a server that
positively doesn't know the tx counts as absent, and a transport failure never
abandons anything, because releasing a UTXO whose tx is actually alive would
invite a double-spend. Verified against the live server, which answers "No such
mempool or blockchain transaction" for an unknown txid.

Stop keying on a value that changes. An RBF bump changes the txid, and
_on_bet_confirmed looked the participant up by bet_txid — so a bumped bet
confirmed under a txid no participant carried, the row stayed "broadcast"
forever, and the scheduler waited on it forever: the round could never close and
the lottery stopped. Handlers now resolve by immutable ids (round_id/user_id,
withdrawal_id), and bump_fee retargets every stored txid — bet_txid,
Withdrawal.txid, Round.payout_txid and UtxoEvent.spent_txid — plus records the
previous one in replaced_by_txid, which was never written at all.

One bad row no longer blocks the rest. The confirmation poller's per-tx lookup
is guarded: a txid the server can't resolve used to abort the whole pass, so
nothing confirmed again until an operator intervened. It also selects plain
columns instead of hydrating entities that outlive their session.

Tests: 6 reconciler cases including "a broken connection must not release coins";
the bet-ordering test probes committed state from an independent session during
the broadcast, and caught a real mistake in the first draft of this change (the
_pending_transaction helper still hardcoded status="pending", so rows were born
already-broadcast and would have got the 6-hour grace instead of 120s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:31:24 +02:00
davideandClaude Opus 5 cc88763a9d Make "one active round" a DB invariant and record why a tx failed
Two schema changes the fixes in the following commits build on (BUGS.md B-09,
B-04):

ix_rounds_single_active is a unique index over the constant expression (1),
restricted to the active statuses, so the table holds any number of closed
rounds and only ever one live one. Rounds never overlapping was previously
enforced only by a read-then-insert in open_new_round_if_needed, which two
concurrent callers can both pass — and a second stuck "open" row blocks every
future round forever, since get_active_round matches on status.

The migration doesn't create that index blind: an instance that already has two
active rounds (the very bug) would fail mid-migration with an opaque
IntegrityError, so it first closes the stale duplicates and keeps the newest —
which is what get_active_round was already doing silently. Verified against a
DB seeded with an 'open' plus a 'closing' round.

pending_transactions.failure_reason is for the reconciler added next: when it
gives up on a transaction, an operator needs to see whether it was dropped or
rejected. The PendingTransaction docstring now also documents the full status
lifecycle (building -> pending -> confirmed | failed), since "building" is new
and load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:30:48 +02:00
davideandClaude Opus 5 9b6a4a240c Give every BUGS.md entry an explicit fix and regression test
The first pass left 11 of the 24 entries with their remedy buried in prose and
only 7 with a named test, while the header claimed a regression test for each.
Every entry now follows one shape: symptom, root cause, *Proposed fix:*,
*Test:* — with the handful that genuinely cannot be unit-tested (Docker image
layout, browser behaviour) marked manual instead of left implied.

A few fixes gained detail while being written out: B-14 needs the empty-token
short-circuit kept ahead of compare_digest, B-15 breaks the current tests'
short JWT secret, B-19 must not update tip_header_hex from a losing header,
and B-20's column ends up holding the previous txid rather than the next one,
so its name is backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:14:52 +02:00
davideandClaude Opus 5 fb734bb818 Document the 24 bugs found in the full-codebase audit
Adds BUGS.md: a severity-ordered backlog from the 2026-07-26 audit of every
Python module, both static frontends and the Docker/Caddy deployment. Each
entry carries symptom, root cause with file references, reproduction and a
proposed fix, plus the regression test it needs — none of these are covered
by the current suite.

The five critical ones share a root cause worth stating up front: the code
treats a broadcast as final and the Electrum connection as never failing, so
there is no reconciliation between the DB's view and the chain's. That leaves
several states the system only leaves via manual DB edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:10:50 +02:00
80 changed files with 6709 additions and 490 deletions
+18
View File
@@ -2,6 +2,19 @@ ELECTRUM_HOST=santantonio.sytes.net
ELECTRUM_PORT=50002
ELECTRUM_USE_SSL=true
# Additional Electrum servers to fall back to, comma-separated. Each entry is
# `host:port` (TLS, the normal case) or `host:port:notls`. The listener rotates
# over the primary above plus these, so one unreachable server costs a single
# reconnect attempt instead of an outage — every deposit credit, broadcast and
# confirmation goes through this one connection, which makes a single server the
# platform's biggest single point of failure. A typo here fails at startup rather
# than during the outage when the fallback is what you need.
# These are the mainnet bootstrap servers from the PalladiumWallet repo
# (src/Core/Chain/ChainProfiles.cs, ChainProfiles.Mainnet.BootstrapServers) — the
# same ones the reference wallet falls back to:
# Example: ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
ELECTRUM_FALLBACK_SERVERS=
# Fernet key protecting the master xprv at rest. Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
XPRV_ENCRYPTION_KEY=
@@ -17,3 +30,8 @@ ADMIN_TOKEN=
# Every business/round parameter (bet amount, round duration/cooldown, min
# amount, fee rate, RBF timeout, fee address) is configured live from the
# admin panel (/admin) instead of here — see docs/guida-admin.md.
# Swagger/ReDoc/the raw OpenAPI JSON expose the entire API surface — admin
# endpoints included — to anyone who requests them. Off by default; set to
# true only for local development, never in production.
ENABLE_API_DOCS=false
+179 -107
View File
@@ -8,168 +8,240 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (76 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), a static test UI for the user-facing flow (`/`), a pending-inclusive balance display (see "Balance display" below), and a Server-Sent Events push channel layered on top of the original polling (see "Real-time updates" below).
All 10 stages of the original build order are code-complete and unit-tested — 253 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle close → draw (real block hash)payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast confirmed change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
Before writing code, always read the "Architecture" section below in full, plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) for the whole 5-phase flow, and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw lifecycle in detail. Every node in these diagrams corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops). Regenerate their companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either one.
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. They were tracked in a `BUGS.md` that was deleted once the list emptied, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
Human-facing guides live in [docs/](docs/) (Italian, per explicit request — an exception to this file's English-only rule below): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md).
Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either.
Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44).
## Commands
The server itself — in development and in production alike — always runs via Docker (see "Deployment" below); there is no supported way to run `uvicorn` directly against this codebase. The venv (`.venv/`) is only for local tooling: running tests, authoring Alembic migrations, and running the one-time scripts that generate the secrets/key material that end up referenced from `.env`.
The server always runs via Docker, in dev and prod alike — there is no supported way to run `uvicorn` directly. The venv (`.venv/`) is only for local tooling: tests, Alembic migrations, and the one-time key/secret scripts.
```bash
source .venv/bin/activate # venv already created at .venv/
pip install -e ".[dev]" # install/update deps
source .venv/bin/activate # venv already created at .venv/
pip install -e ".[dev]"
alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py (applied automatically by the container's startup command — see Deployment — never run `alembic upgrade head` manually)
alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env; see Deployment for where MASTER_KEY_PATH should point)
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print the existing master xprv (asks for confirmation first)
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: bring your own externally-generated xprv instead of generating one (getpass prompt, --overwrite to replace)
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env)
PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation)
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # run all tests
python -m pytest tests/unit/test_hd.py # run one test file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run a single test
python -m pytest # all 253 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
`.env` (gitignored) holds real secrets for local dev; `.env.example` documents the required keys and how to generate them.
`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`.
`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set.
## Deployment (Docker + Caddy)
The app is always run via Docker — dev and prod alike use the same `docker-compose.yml`, just with a different `SITE_ADDRESS` (see below); there's no separate dev-mode compose file or bare-`uvicorn` workflow. `docker-compose.yml` runs two containers: `app` (this codebase, built by `Dockerfile`, runs `alembic upgrade head` then `uvicorn`) and `caddy` (reverse proxy + automatic TLS). `.env` holds the app secrets; `docker-compose.yml` overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted master key, logs — all gitignored, persist across container restarts). Set `MASTER_KEY_PATH` in `.env` itself to the host-side equivalent, `./data/keys/master.xprv.enc`, so the venv-run key-generation scripts above (see "Commands") write to the exact same file the container reads — one source of truth for the key, whichever way it was generated.
Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key.
```bash
mkdir -p data/db data/keys data/logs # one-time: host dirs bind-mounted into the app container
# one-time: generate the master key via the venv script above (scripts/generate_master_key.py),
# not via `docker compose run` — MASTER_KEY_PATH in .env already points at ./data/keys/
docker compose up -d --build # build + start app and caddy — same command for dev and prod
docker compose logs -f app # tail app logs (also written to ./data/logs/app.log)
docker compose down # stop
mkdir -p data/db data/keys data/logs # one-time
docker compose up -d --build # dev and prod alike
docker compose logs -f app # also written to ./data/logs/app.log
docker compose down
```
Caddy's site address comes from `SITE_ADDRESS` (env var on the host, read by `docker-compose.yml`):
- **Dev, no domain**: leave it unset (defaults to `localhost`). Caddy detects it isn't a public hostname and issues a self-signed cert from its own internal CA — browsers will warn on first visit, expected for local testing (`curl -k` or click through).
- **Production, with a domain**: `SITE_ADDRESS=lottery.example.com docker compose up -d` (DNS must already point at the server, ports 80+443 reachable). Caddy automatically requests and renews a real Let's Encrypt certificate — no other config needed.
`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).
Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a crash mid-round auto-restarts the container — which hits the scheduler-resume gap below (a round stuck in `closing`/`drawing`/`paying_out` at restart stays stuck). Don't treat this as unattended-safe until that gap is closed.
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
## Tech stack (MVP)
## Tech stack
- **Backend language**: Python.
- **PLM node access**: Electrum protocol only (no full node/P2P). Bootstrap server for development: `santantonio.sytes.net:50002` (SSL).
- **Auth**: Argon2 password hashing + JWT sessions.
- **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git.
- **Operational config**: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the `round_config` DB table (single row, `app/rounds/config.py`) and is only editable live via the admin dashboard (`/admin`) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since those genuinely need a restart.
- **Round cooldown**: `round_cooldown_seconds` — gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision.
- **Maintenance pause**: `RoundConfig.paused` (default `false`), toggled via `POST /admin/pause` / `POST /admin/resume` (a dedicated "Manutenzione" card in `/admin`'s Parametri section, not a plain config field — it's a deliberate operator action, audit-logged as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops opening a *next* round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally). `GET /rounds/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner.
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
- Auth: Argon2 hashing + JWT (HS256, 24h, **no revocation** — B-34).
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
## PLM network parameters
## PLM network parameters (mainnet)
Source of truth: `PalladiumWallet` repo, [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs) and [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs) — always re-check that repo if a value is needed that isn't listed here, rather than guessing.
Source of truth: the `PalladiumWallet` repo [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs), [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs). Re-check there for anything not listed here rather than guessing; its `ChainProfiles.Mainnet.BootstrapServers` is also where `.env.example`'s suggested `ELECTRUM_FALLBACK_SERVERS` come from.
Mainnet:
- BIP44/84 coin type: `746` (i.e. HD path `m/84'/746'/0'/0/index`)
- Bech32 HRP: `plm`
- P2PKH address version byte: `55` (addresses start with `P`)
- P2SH address version byte: `5`
- WIF prefix: `0x80`
- Block time: 120s
- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.cs`
| | |
|---|---|
| BIP44/84 coin type | `746``m/84'/746'/0'/0/index` |
| Bech32 HRP | `plm` |
| P2PKH / P2SH version byte | `55` (addresses start with `P`) / `5` |
| WIF prefix | `0x80` |
| Block time | 120s |
| BIP32 ext-key headers | see `ExtKeyHeaders` in `ChainProfiles.cs` |
## Business parameters
| Parameter | Value | Where |
|---|---|---|
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit |
| Round duration / cooldown | 600s / 30s | `RoundConfig` |
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
| Min deposit | none | — |
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
| Max inputs per tx | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome.
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
## Code map
| Package | Contents |
|---|---|
| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` |
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`; `app/api/errors.py` holds the error contract |
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` |
| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories |
| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) |
| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers |
| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) |
| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
| `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` |
## Background tasks
`app/main.py`'s lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals.
| Task | File | Cadence | Role |
| --- | --- | --- | --- |
| `ElectrumListener` | `electrum/listener.py` | reconnect loop, 60s keepalive | the single connection; subscribes headers + every user's scripthash, credits deposits |
| `RoundScheduler` | `rounds/scheduler.py` | 5s | opens/closes rounds, draws, triggers and retries payouts |
| `ConfirmationPoller` | `tx/confirmation.py` | 10s | `pending``confirmed` via per-kind handlers registered by `app/{bets,rounds,withdrawals}/confirmation.py` — imported for that side effect in `main.py`, **don't "clean up" those imports** |
| `RbfBumper` | `tx/broadcast.py` | 30s | fee-bumps anything past `rbf_timeout_seconds` |
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
## Electrum connection
One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
## Architecture — the 5 phases
Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd).
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
**DRAW** — configurable timer (default 600s):
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.
**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion:
1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline.
2. **The draw block**`_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block.
3. **Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it.
At 120s blocks that's ~46 min worst case (last bet confirms right at the deadline), ~24 min best case — independent of `draw_animation_seconds`.
## Balance display
`place_bet`/`request_withdrawal` (`app/bets/service.py`, `app/withdrawals/service.py`) select whole UTXOs to cover the amount (`select_utxos`, largest-first) and mark every selected UTXO `spent_txid` immediately at broadcast time — well before the tx has any confirmations. `User.cached_balance_sats` (`recompute_balance`, `app/wallet/balance.py`) only sums confirmed, unspent UTXOs, so right after a bet/withdrawal it understates the user's real balance by the entire unconfirmed change amount, which is often far larger than the amount actually moving.
`place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change often far more than the amount actually moving.
`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without touching what's actually spendable: it decodes the raw tx of every in-flight (`status="pending"`) bet/withdrawal `PendingTransaction` belonging to the user and sums whichever outputs pay back to the user's own address, adding that to `cached_balance_sats`. `GET /users/me` returns both `balance_sats` (confirmed-only still what withdrawal-max and internal spend logic use, since only confirmed UTXOs are actually spendable) and `pending_balance_sats` + `has_pending` (what the frontend displays, colored green when settled and amber while `has_pending` is true).
`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets `balance_pending_confirmation` instead of a flat `insufficient_balance` (B-37), so the error doesn't contradict what the user is looking at.
## Real-time updates (SSE)
`GET /rounds/stream` (`app/api/routes/rounds.py`) is a Server-Sent Events channel layered *on top of* the original polling loops in `app/static/index.html`/`admin.html` — polling is the fallback, not replaced, so a blocked/dropped SSE connection just degrades to the pre-existing behavior. The channel carries no payload and needs no auth: it's purely a "something changed, go refetch" ping; personalization (e.g. `user_played` below) still lives entirely in the normal per-user REST endpoints.
`GET /rounds/stream` is **additive to** the polling loops in the two SPAs, not a replacement — a blocked or dropped stream just degrades to the old behaviour. No payload, no auth: it's a "something changed, go refetch" ping, with all personalization (e.g. `user_played`) staying in the authenticated REST endpoints. The generator re-checks `request.is_disconnected()` every 5s and sends a keep-alive comment every 20s, so neither a client that vanished without a clean close nor a proxy idle timeout breaks it silently.
`app/rounds/events.py`'s `RoundEventBroadcaster` (module-level singleton `broadcaster`) is a simple in-process pub/sub one `asyncio.Queue` (maxsize 1, so redundant notifications coalesce) per connected SSE client. `broadcaster.publish()` is called from every point that changes something a dashboard would want to know about: a new round opening (`rounds/service.py`), every round status transition (`rounds/scheduler.py`: closing/drawing/paying_out/closed), a bet or withdrawal broadcast (`bets/service.py`, `withdrawals/service.py`), any pending tx confirming — bet/withdrawal/payout (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new block tip arriving (`electrum/listener.py`the exact moment the "drawing" phase is waiting on).
`rounds/events.py`'s `RoundEventBroadcaster` (singleton `broadcaster`) is in-process pub/sub, one `asyncio.Queue(maxsize=1)` per client so redundant notifications coalesce. `publish()` is called on: a round opening (`rounds/service.py`), every status transition (`scheduler.py`), a bet or withdrawal broadcast, any pending tx confirming (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new tip arriving (`electrum/listener.py`exactly what the drawing phase waits on). The rollback paths (`_release_failed_bet`, `_release_failed_withdrawal`, the reconciler's abandon) publish too — a rollback moves as much state as the success path, so it must ping the dashboards the same way (B-49).
Deliberate scope decisions, not oversights:
- **Single-process only, no cross-worker fan-out.** Fine for the current deployment (one uvicorn process, see `docker-compose.yml`). A multi-worker/multi-container deployment would need a shared channel (e.g. Redis pub/sub) instead — don't add that speculatively before it's actually needed.
- **Generic broadcast, not a per-user channel.** Every connected client refetches on every event, even ones irrelevant to them. Acceptable at the expected scale (~100 concurrent users); a targeted per-user channel would need auth on the SSE endpoint and server-side knowledge of who's affected by each event — real engineering work, only worth it well past current expected concurrency.
- `MAX_SUBSCRIBERS` (default 500, `app/rounds/events.py`) is a defensive cap only — past it, `GET /rounds/stream` returns 503 instead of opening a stream, and the client's `EventSource` just falls back to polling. Not a substitute for the app-wide "no rate limiting anywhere" gap (see Known gaps).
Deliberate scope limits, not oversights: **single-process only** (fine for one uvicorn process; a multi-worker deployment needs e.g. Redis pub/sub — don't add it speculatively); **generic broadcast, not per-user** (everyone refetches on every event; acceptable at ~100 concurrent users, and a targeted channel would need auth on the stream plus server-side knowledge of who each event affects); `MAX_SUBSCRIBERS` (500) is defensive only — past it the endpoint returns 503 and `EventSource` falls back to polling, which being global and unauthenticated makes the cap itself a cheap DoS of the realtime feature (B-38).
Frontend: both `index.html` and `admin.html` open an `EventSource('/rounds/stream')` and, on an `update` message *or* on `open` (which fires on the initial connection and every automatic reconnect), immediately re-run the same refresh calls polling would eventually do — this matters most right after a dropped connection reconnects, closing most of the "missed while disconnected" gap.
Both SPAs refresh on an `update` message *or* on `open` — the latter fires on every automatic reconnect, closing most of the "missed while disconnected" gap.
## MVP business parameters
## Transaction lifecycle and reconciliation
- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant.
- Prize split: **70% winner / 30% fees**, hardcoded in `rounds/scheduler.py` (`winner_share = pool_amount_sats * 70 // 100`) — unlike bet amount, this ratio is not in `RoundConfig` and would need a code change, not an admin-panel edit.
- Minimum withdrawal amount: equal to the current bet amount (`RoundConfig.bet_amount_sats`), enforced in `app/withdrawals/service.py` — not a separate admin-configurable field. Deposits have no server-side minimum check.
- Confirmations required for all tx types (deposit, bet, payout, withdrawal): **1**, hardcoded in `tx/confirmation.py` — not configurable, per the design decision below.
Everything that spends money is written **before** it is broadcast and resolved against the chain afterwards; this is what makes the system recover without manual DB edits. `PendingTransaction.status`: `building``pending``confirmed`, or `failed`.
## What is PLM Lottery
- `building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record.
- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed`**502**, since the network refused it, not the caller.
- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41).
A periodic-round lottery system built on a Bitcoin-like coin (PLM, mainnet). Each user gets a dedicated P2WPKH address (server-side HD wallet); they deposit PLM to that address, place a fixed-cost bet to enter the current round, and when the round closes a winner is drawn who receives 70% of the prize pool (the remaining 30% goes to fees).
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
## Architecture (from the flowchart subgraphs)
**Payouts retry, and are guarded against paying twice.** Every tick re-examines a `paying_out` round: `_retry_payout_if_due` throttles to one attempt per 60s using the latest `payout_failed` audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in `_trigger_payout` writes one, so `/admin` shows *why* a round is stuck. Before building, `_trigger_payout` refuses if a `building`/`pending` payout already exists for the round, and `_reserved_payout_outpoints` excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.
The flow is organized into 5 phases (see [flowchart/platform-overview.mmd](flowchart/platform-overview.mmd) for the full-platform diagram, and [flowchart/round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw phase in detail):
**"At most one active round" is a DB invariant**, not a convention: `ix_rounds_single_active` (unique index over the constant `(1)`, restricted to the active statuses) makes a concurrent second insert fail cleanly, and `open_new_round_if_needed` recovers by adopting the winner's round (max 3 attempts).
- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals.
- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic.
- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast.
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a generic "drawing" status box (phase label, e.g. "Pagamento al vincitore in corso…") to **every** viewer on every dashboard for the whole closing/drawing/paying_out phase — this one is purely cosmetic status text, driven directly by `status`, no gating. Independently and *additively* (not instead of it), a personalized "Hai vinto!/Non hai vinto" box appears only for users where `GET /rounds/current`'s `user_played` field is true (computed via `app/auth/dependencies.py:get_optional_user`, since this endpoint is reachable logged-out too) — everyone else has nothing to reveal and never sees it. That reveal is additionally delayed by at least `draw_animation_seconds` (admin-configurable, default 20s) for cosmetic suspense, anchored to the round's server-provided `closes_at` timestamp rather than a client-side "first seen" time (so reloading the page can't reset the countdown), and decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set. Once revealed, the result is persisted in the browser's `localStorage` (`plm_persisted_result`) so it survives a page refresh even after the round moves past `paying_out` into `closed` — at which point `get_active_round` stops returning that round at all and `winner_user_id` disappears from `GET /rounds/current` entirely. `GET /users/me/last-round-result` (`app/api/routes/users.py`) is a durable, DB-backed backstop for a user who reloads on a browser/device that missed the live reveal window completely: it looks up the most recent *closed* round the user has a `RoundParticipant` row in. See `app/static/index.html`'s `refreshRound`/`checkLastRoundResult` for the full reveal logic.
- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern.
## Frontends
PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address.
Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`.
**Three separate on-chain confirmations, not one, between the timer hitting zero and the payout landing** — a common point of confusion, worth spelling out explicitly:
1. **Last bet's confirmation** (`scheduler.py`'s `_tick`, the `pending_count` check before `_close_and_draw`) — the round doesn't even flip to `"closing"` until every already-broadcast bet has its 1st confirmation. This can already have happened before the timer expired; it's the earliest of the three and not necessarily tied to the deadline at all.
2. **The draw block** (`_wait_for_next_block`, waits for `tip_height > tip_at_close`, where `tip_at_close` is recorded only once step 1 is done) — by construction this must be a **later, different block** than whichever one confirmed the last bet in step 1.
3. **Payout confirmation**`_trigger_payout` broadcasts only after step 2's block is known, then registers a `PendingTransaction(kind="payout")` that the same generic `ConfirmationPoller` (`app/tx/confirmation.py`) waits on independently — this needs **yet another, later block** than step 2's, since the payout can't be built before the winner is known.
- **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher.
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
So worst case (last bet confirms right at the deadline) is ~3 block times end-to-end; best case (all bets already confirmed before the timer hit zero) is ~2 (draw block + payout block). At PLM's 120s block time that's roughly 46 minutes worst case, 24 minutes best case — independent of `draw_animation_seconds`, which only sets a cosmetic minimum for the frontend animation.
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
## Internationalization (user-facing page only)
## Internationalization (`/` only)
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is available everywhere. Language comes from `localStorage.plm_lang`, falling back to `navigator.language`, falling back to `en`; the switcher lives in the **chain-bar, not the navbar**, deliberately the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need the switch.
`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` `navigator.language` `en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it.
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`), applied by `applyStaticTranslations(root?)` on `DOMContentLoaded` and on every switch. Anything rendered from server data is built with `t()` in `app.js` instead, and re-rendered by `onLanguageChange()` — an element must be in one camp or the other, never both, or the two mechanisms overwrite each other (this is why `#bet-btn` has no `data-i18n`: its label carries the admin-configurable bet amount, so `renderBetButton()` owns it).
- **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string.
- `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`).
- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it.
- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string.
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
**API error contract** (`app/api/errors.py`): the API is single-language by design. User-facing failures answer with a structured `detail` `{"code", "message", "params"}` where `message` is English for non-dashboard consumers and `code` is what the frontend maps onto `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). Domain exceptions (`BetError`, `WithdrawalError`) subclass `ApiError` and carry the code from where the failure actually happens; `str(exc)` is still the English message. When adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass interpolated values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling automatically) rather than baking them into the English text.
## Admin dashboard and test UI
Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler):
- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu.
- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL.
Both pages talk to the same JSON API everything else uses; there's no separate "admin API" vs "user API" boundary beyond the `require_admin` dependency.
**API error contract** (`app/api/errors.py`) the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
## Non-obvious domain decisions
These choices were made explicitly during design (not derivable from reading a single file) and must be respected in any implementation:
Explicit design choices, not derivable from any single file — respect them:
- Private keys (xprv) are generated and held **server-side** this is not a non-custodial system: the user never controls their own keys until they make an explicit withdrawal.
- The user's personal deposit address always doubles as the winnings-receiving address: there is no separate "winner address".
- 1 confirmation is the chosen threshold for all tx types (deposits, bets, payouts, withdrawals): don't introduce different thresholds (e.g. 3 or 6 confirmations) without an explicit decision.
- The draw algorithm (node R) is deliberately simple and should be treated as a replaceable/pluggable component, not the final design — don't architect around its current implementation.
- The admin panel can export any user's raw WIF private key (`GET /admin/users/{id}/privkey`, `app/wallet/hd.py:derive_user_wif`). This is intentional, not a vulnerability to fix: the server already holds the master key everything derives from (custodial by design, see above), so this only exposes through the API something an operator could already do via a script. Every access is written to `audit_log` (`admin_privkey_accessed`) — don't remove that logging when touching this endpoint.
- RBF fee bumps are paid by whoever's change output the tx pays back to — the user for bets/withdrawals, the pool for payouts — never by the fixed counterparty amount (recipient/winner/fee-address outputs are untouched; only the sender's own change shrinks). See `bump_fee` in `app/tx/broadcast.py`.
- Keys are generated and held **server-side**: this is **custodial**. The user controls nothing until they withdraw.
- The deposit address *is* the winnings address there is no separate "winner address".
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision.
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
## Known gaps / TODO
Not blockers for reading the code, but must be addressed before this is production-ready:
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
- **Scheduler doesn't resume mid-flight rounds after a restart.** `rounds/scheduler.py`'s `_tick()` only acts on rounds with `status == "open"`. If the process restarts while a round is `closing`/`drawing`/`paying_out`, it's permanently stuck — nothing re-enters `_wait_for_next_block` or retries `_trigger_payout`. Needs a startup routine that inspects in-progress rounds and resumes (or a periodic "unstick" check) before this can run unattended.
- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError` and needs manual operator intervention. Documented in `tx/broadcast.py`.
- **Payout retry**: if `_trigger_payout` fails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck in `paying_out` with no automatic retry.
- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM as of this commit.
- **No general user-facing history endpoints** (list my own bets / withdrawals / past rounds) — `GET /users/me/last-round-result` covers exactly one case (the outcome of the most recent *closed* round the user played in, as a reveal-persistence backstop; see DRAW above), not a real history. The admin side has more (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's still no "my own full history" equivalent for a logged-in user.
- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity or audit trail of *who* changed config (the `audit_log` table records *what* changed, not which operator did it). This token now gates a lot more than config (user list, private key export, round/audit history), so its blast radius if leaked is correspondingly larger.
- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin).
- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite.
- **`docker-compose.yml`'s `restart: unless-stopped`** on the app container means a crash mid-round auto-restarts straight into the scheduler-resume gap above — see the Deployment section.
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
- **No rate limiting anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.
+16
View File
@@ -13,5 +13,21 @@
not path /rounds/stream
}
encode @not_sse gzip
# B-43: Caddy adds none of these on its own. The JWT lives in
# localStorage, so any XSS exfiltrates it — CSP is the main mitigation.
# script-src/style-src need 'unsafe-inline' because both SPAs
# (app/static/index.html, admin.html) use inline onclick handlers and
# style="" attributes throughout; removing those is a separate,
# larger refactor, not a header change. fonts.googleapis.com/gstatic.com
# are the one external asset (the Google Fonts @import in style.css/admin.css).
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
}
reverse_proxy app:8123
}
+16 -17
View File
@@ -13,31 +13,30 @@ production-ready.
## Quick start
The server always runs via Docker (app + Caddy reverse proxy with automatic
TLS) — in dev and production alike, with only `SITE_ADDRESS` differing
between the two. There's no supported way to run `uvicorn` directly; the
venv is only for local tooling (tests, Alembic migrations, the one-time key
scripts) — see [CLAUDE.md](CLAUDE.md#commands).
```bash
cp .env.example .env # then fill in the generated secrets, see docs/setup.md
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
PYTHONPATH=. python scripts/generate_master_key.py
alembic upgrade head
uvicorn app.main:app --reload --port 8123
```
Open `http://127.0.0.1:8123/` for the test UI, `http://127.0.0.1:8123/admin`
for the admin dashboard, `http://127.0.0.1:8123/docs` for the interactive API
docs.
Or run the whole stack (app + Caddy reverse proxy with automatic TLS) via
Docker:
```bash
mkdir -p data/db data/keys data/logs
docker compose run --rm app python scripts/generate_master_key.py
docker compose up -d --build
```
Open `https://localhost/` for the test UI, `https://localhost/admin` for the
admin dashboard (a self-signed-certificate warning on first visit is
expected in dev — accept it, or use `curl -k`). The interactive API docs at
`/docs` are disabled by default (they'd otherwise expose the whole API
surface, admin endpoints included) — set `ENABLE_API_DOCS=true` in `.env` to
enable them.
See [docs/setup.md](docs/setup.md) and
[docs/running-the-server.md](docs/running-the-server.md) for the full
walkthrough (both workflows, dev vs. production TLS).
walkthrough (secrets, master key generation, production TLS with a real
domain).
## Documentation
@@ -62,7 +61,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
76 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
232 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+17
View File
@@ -0,0 +1,17 @@
from fastapi import Request
def client_ip(request: Request) -> str:
"""The caller's real IP, from Caddy's X-Forwarded-For (see Caddyfile) —
request.client.host would otherwise be the reverse proxy's own address, not
the caller's. Falls back to request.client.host only if the header is
somehow missing (e.g. the app container hit directly, bypassing Caddy).
Shared by the login/registration throttles (B-33) and the SSE per-IP
subscriber cap (B-38) so the two can't drift into different notions of
"the client's IP".
"""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+82 -24
View File
@@ -1,27 +1,41 @@
import json
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.config import settings
from app.db.models import AuditLog, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.wallet.address import is_valid_plm_address
from app.wallet.hd import derive_user_wif
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
router = APIRouter(prefix="/admin", tags=["admin"])
async def require_admin(x_admin_token: str = Header(default="")) -> None:
if not settings.admin_token or x_admin_token != settings.admin_token:
# An unset ADMIN_TOKEN denies everything — checked first, since compare_digest
# on two empty strings returns True and would otherwise open the panel to
# anyone on an instance that never configured a token.
if not settings.admin_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
# compare_digest raises TypeError on a str containing non-ASCII characters
# (B-46) -- comparing the UTF-8 bytes instead accepts any input safely.
if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()):
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
# (/admin/pause, /admin/resume), and accepting it on PUT /config as well gave the
# operator an unlogged way to stop the lottery (B-10). It stays in the response
# model, so the dashboard still reads its current value from here.
_CONFIG_FIELDS = (
"fee_address",
"bet_amount_sats",
@@ -30,7 +44,6 @@ _CONFIG_FIELDS = (
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"paused",
)
@@ -46,18 +59,41 @@ class RoundConfigResponse(BaseModel):
class RoundConfigUpdate(BaseModel):
"""Bounds are enforced here rather than trusting the operator: a value like
fee_rate_sat_vb=0 produces transactions no node will relay (stalling every bet,
payout and withdrawal), and round_duration_seconds=0 expires a round the instant
it opens. `paused` is not accepted — see _CONFIG_FIELDS."""
# An unvalidated fee_address was the worst of the lot: a malformed one wedged the
# payout with an unhandled EmbitError, and a well-formed *foreign* one (bc1...)
# parses fine as a witness program, so every round's 30 % commission would be
# broadcast to a script nobody holds the key for (B-05).
fee_address: str | None = None
bet_amount_sats: int | None = None
round_duration_seconds: int | None = None
round_cooldown_seconds: int | None = None
fee_rate_sat_vb: int | None = None
rbf_timeout_seconds: int | None = None
draw_animation_seconds: int | None = None
paused: bool | None = None
bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000)
round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600)
round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600)
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
@field_validator("fee_address")
@classmethod
def _validate_fee_address(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not is_valid_plm_address(value):
raise ValueError(
"fee_address must be a valid PLM bech32 address (plm1...) — an address from "
"another chain would send every round's commission somewhere unspendable"
)
return value
def _config_response(config) -> RoundConfigResponse:
return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_FIELDS})
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
fields["paused"] = config.paused
return RoundConfigResponse(**fields)
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
@@ -72,10 +108,22 @@ async def update_config(
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
) -> RoundConfigResponse:
config = await get_round_config(session)
# Diff computed before assignment so the audit entry records both sides. Without
# it, the most sensitive setting in the system (fee_address — where 30 % of every
# pool goes) could be changed without leaving any trace at all (B-10).
changes: dict[str, dict] = {}
for field in _CONFIG_FIELDS:
value = getattr(body, field)
if value is not None:
setattr(config, field, value)
if value is None:
continue
previous = getattr(config, field)
if previous == value:
continue
changes[field] = {"from": previous, "to": value}
setattr(config, field, value)
if changes:
await write_audit_log(session, "config_updated", changes)
await session.commit()
return _config_response(config)
@@ -118,7 +166,7 @@ async def list_users(session: AsyncSession = Depends(get_session)) -> list[Admin
username=u.username,
address=u.address,
balance_sats=u.cached_balance_sats,
created_at=u.created_at.isoformat(),
created_at=isoformat_utc(u.created_at),
)
for u in users
]
@@ -170,6 +218,11 @@ async def reset_user_password(
new_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(new_password)
# B-34: this endpoint exists precisely for the "account compromised" case —
# without bumping token_version, whoever was already logged in (the
# attacker, if that's who prompted the reset) stayed logged in on their
# existing token until it naturally expired, unaffected by the reset.
user.token_version += 1
await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id)
await session.commit()
return AdminPasswordResetResponse(username=user.username, new_password=new_password)
@@ -191,7 +244,9 @@ class AdminRoundResponse(BaseModel):
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]:
async def list_rounds(
session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500)
) -> list[AdminRoundResponse]:
rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all()
winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None}
winners = {}
@@ -203,8 +258,8 @@ async def list_rounds(session: AsyncSession = Depends(get_session), limit: int =
AdminRoundResponse(
id=r.id,
status=r.status,
opened_at=r.opened_at.isoformat(),
closed_at=r.closed_at.isoformat() if r.closed_at else None,
opened_at=isoformat_utc(r.opened_at),
closed_at=isoformat_utc(r.closed_at),
draw_block_height=r.draw_block_height,
draw_block_hash=r.draw_block_hash,
winner_user_id=r.winner_user_id,
@@ -231,7 +286,7 @@ class AdminAuditLogResponse(BaseModel):
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
session: AsyncSession = Depends(get_session), limit: int = 200
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
) -> list[AdminAuditLogResponse]:
entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
return [
@@ -241,7 +296,7 @@ async def list_audit_log(
payload=json.loads(e.payload_json),
user_id=e.user_id,
round_id=e.round_id,
created_at=e.created_at.isoformat(),
created_at=isoformat_utc(e.created_at),
)
for e in entries
]
@@ -268,10 +323,13 @@ class AdminPendingTransactionResponse(BaseModel):
)
async def list_pending_transactions(
session: AsyncSession = Depends(get_session),
limit: int = Query(default=50, ge=1, le=500),
status_filter: str | None = Query(default=None, alias="status"),
) -> list[AdminPendingTransactionResponse]:
entries = (
await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc()))
).all()
query = select(PendingTransaction).order_by(PendingTransaction.id.desc())
if status_filter is not None:
query = query.where(PendingTransaction.status == status_filter)
entries = (await session.scalars(query.limit(limit))).all()
return [
AdminPendingTransactionResponse(
id=p.id,
@@ -283,7 +341,7 @@ async def list_pending_transactions(
current_txid=p.current_txid,
fee_rate_sat_vb=p.fee_rate_sat_vb,
attempt_count=p.attempt_count,
broadcast_at=p.broadcast_at.isoformat(),
broadcast_at=isoformat_utc(p.broadcast_at),
replaced_by_txid=p.replaced_by_txid,
)
for p in entries
+8 -1
View File
@@ -36,7 +36,14 @@ async def create_bet(
try:
participant = await place_bet(session, listener.client, user)
except BetError as exc:
raise from_api_error(status.HTTP_400_BAD_REQUEST, exc) from exc
# A rejected broadcast isn't the client's fault — it's the network
# refusing our transaction, so it answers 502 rather than 400 (B-07).
code = (
status.HTTP_502_BAD_GATEWAY
if exc.code == "broadcast_failed"
else status.HTTP_400_BAD_REQUEST
)
raise from_api_error(code, exc) from exc
return BetResponse(
round_id=participant.round_id,
+32 -7
View File
@@ -8,11 +8,13 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_optional_user
from app.db.models import RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import RoundEventCapacityError, broadcaster
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
from app.rounds.service import get_active_round
router = APIRouter(prefix="/rounds", tags=["rounds"])
@@ -45,9 +47,14 @@ async def round_stream(request: Request) -> Response:
That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this
returns 503 rather than opening a stream, and the browser's EventSource
just retries later while the frontend keeps working off polling meanwhile.
Concurrent streams are additionally capped per client IP (B-38): past
MAX_SUBSCRIBERS_PER_IP, opening one more evicts that IP's own oldest
connection rather than refusing the new one or letting a single source
exhaust the global cap and degrade every other user.
"""
try:
queue = broadcaster.subscribe()
queue = broadcaster.subscribe(client_ip(request))
except RoundEventCapacityError:
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
@@ -58,7 +65,9 @@ async def round_stream(request: Request) -> Response:
if await request.is_disconnected():
break
try:
await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
item = await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
if item is EVICTED:
break # this IP opened another stream past its per-IP cap
yield "event: update\ndata: {}\n\n".format(json.dumps({}))
ticks_since_keepalive = 0
except asyncio.TimeoutError:
@@ -90,6 +99,10 @@ class CurrentRoundResponse(BaseModel):
winner_amount_sats: int | None = None
draw_block_height: int | None = None
draw_block_hash: str | None = None
# B-36: set only while status == "drawing", so the frontend can show "still
# waiting for a block" rather than a countdown implying a bounded wait — this
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
draw_waiting_since: str | None = None
chain_tip_height: int | None = None
lottery_paused: bool = False
user_played: bool = False
@@ -118,6 +131,16 @@ async def current_round(
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
) or 0
# The pool is the sum of what the participants' bets actually paid into the pool
# address — each one is already net of that bet's network fee. Deriving it from
# participant_count * the *current* bet_amount_sats instead overstated it, and
# silently changed the advertised jackpot of a round in progress whenever an
# operator edited the bet amount (B-11).
pool_amount_sats = await session.scalar(
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
RoundParticipant.round_id == round_.id
)
) or 0
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
@@ -137,10 +160,11 @@ async def current_round(
await session.commit()
# Shown to players as "jackpot": the winner's 70% share of the pool (same
# split rounds/scheduler.py applies at payout time), not the full pool
# what's displayed should match what the winner actually receives.
pool_amount_sats = participant_count * config.bet_amount_sats
# Shown to players as "jackpot": the winner's 70% share of the pool (same split
# rounds/scheduler.py applies at payout time), not the full pool. It remains an
# upper bound by the payout tx's own fee, which is deducted from the winner's
# share and isn't knowable until the payout is built — a few hundred sat on a
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
jackpot_sats = pool_amount_sats * 70 // 100
return CurrentRoundResponse(
@@ -157,6 +181,7 @@ async def current_round(
winner_amount_sats=round_.winner_amount_sats,
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
user_played=user_played,
+20 -9
View File
@@ -4,16 +4,15 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user
from app.auth.security import hash_password, verify_password
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session
from app.wallet.balance import compute_pending_balance
router = APIRouter(prefix="/users", tags=["users"])
_MIN_PASSWORD_LENGTH = 8
class MeResponse(BaseModel):
id: int
@@ -38,7 +37,7 @@ async def me(
balance_sats=user.cached_balance_sats,
pending_balance_sats=pending_balance_sats,
has_pending=has_pending,
created_at=user.created_at.isoformat(),
created_at=isoformat_utc(user.created_at),
)
@@ -47,12 +46,16 @@ class ChangePasswordRequest(BaseModel):
new_password: str
@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT)
class ChangePasswordResponse(BaseModel):
access_token: str
@router.post("/me/change-password", response_model=ChangePasswordResponse)
async def change_password(
body: ChangePasswordRequest,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
) -> ChangePasswordResponse:
"""Self-service password change — requires the current password, unlike the
admin-only /admin/users/{id}/reset-password (which is for a user who's
actually locked out and can't provide it)."""
@@ -60,16 +63,24 @@ async def change_password(
raise http_error(
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
)
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
if len(body.new_password) < MIN_PASSWORD_LENGTH:
raise http_error(
status.HTTP_400_BAD_REQUEST,
"password_too_short",
f"new password must be at least {_MIN_PASSWORD_LENGTH} characters",
minimum=_MIN_PASSWORD_LENGTH,
f"new password must be at least {MIN_PASSWORD_LENGTH} characters",
minimum=MIN_PASSWORD_LENGTH,
)
user.password_hash = hash_password(body.new_password)
# B-34: bumping token_version invalidates every token issued before this
# point — including this very request's own bearer token, and any an
# attacker who knew the old password might be holding. A fresh token is
# handed back so *this* session keeps working without forcing a re-login;
# every other open session (this user's other devices, or an attacker's)
# gets "session_expired" on its next request.
user.token_version += 1
await session.commit()
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
class LastRoundResultResponse(BaseModel):
+6 -1
View File
@@ -44,7 +44,12 @@ async def create_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats
)
except WithdrawalError as exc:
raise from_api_error(status.HTTP_400_BAD_REQUEST, exc) from exc
code = (
status.HTTP_502_BAD_GATEWAY # the network refused it, not the caller (B-07)
if exc.code == "broadcast_failed"
else status.HTTP_400_BAD_REQUEST
)
raise from_api_error(code, exc) from exc
return WithdrawalResponse(
txid=withdrawal.txid,
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime, timezone
def isoformat_utc(dt: datetime | None) -> str | None:
"""Serialize a datetime for API responses, stamping it UTC first.
Every DateTime column is written via app.db.models.utcnow() but SQLite/aiosqlite
round-trips it as a naive datetime, so a bare .isoformat() drops the "Z"/offset
and JavaScript's `new Date()` on the frontend parses the result as local time
instead of UTC (B-35). All stored values are UTC in practice, so a naive value
can be safely stamped rather than converted.
"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
+12 -3
View File
@@ -16,13 +16,19 @@ async def get_current_user(
session: AsyncSession = Depends(get_session),
) -> User:
try:
user_id = decode_access_token(credentials.credentials)
user_id, token_version = decode_access_token(credentials.credentials)
except Exception as exc:
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc
user = await session.scalar(select(User).where(User.id == user_id))
if user is None:
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found")
if user.token_version != token_version:
# B-34: a password change (self-service or admin reset) bumps
# token_version, so a token issued before it — including one an
# attacker who had the old password is still holding — reads as
# expired rather than staying valid until it naturally times out.
raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "token has been superseded")
return user
@@ -37,7 +43,10 @@ async def get_optional_user(
if not auth_header.startswith("Bearer "):
return None
try:
user_id = decode_access_token(auth_header.removeprefix("Bearer "))
user_id, token_version = decode_access_token(auth_header.removeprefix("Bearer "))
except Exception:
return None
return await session.scalar(select(User).where(User.id == user_id))
user = await session.scalar(select(User).where(User.id == user_id))
if user is None or user.token_version != token_version:
return None
return user
+75
View File
@@ -0,0 +1,75 @@
import time
from dataclasses import dataclass
@dataclass
class _Bucket:
failures: int = 0
locked_until: float = 0.0
last_failure_at: float = 0.0
class RateLimiter:
"""In-process failed-attempt throttle with exponential backoff, keyed by an
arbitrary string (username, IP...). Single-process-only, like UserLocks
(app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment
would need a shared store (Redis) instead (B-33).
Brute-forcing a login here isn't a spammy client to be capped at N req/s —
it's an attempt to withdraw someone else's funds — so failures are
penalized with a delay that doubles each time past `threshold` free
attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back
to zero once failures stop, so a shared/NAT IP isn't punished forever for
someone else's earlier mistakes.
"""
def __init__(
self,
threshold: int = 5,
base_delay: float = 2.0,
max_delay: float = 300.0,
decay_seconds: float = 900.0,
) -> None:
self._threshold = threshold
self._base_delay = base_delay
self._max_delay = max_delay
self._decay_seconds = decay_seconds
self._buckets: dict[str, _Bucket] = {}
def retry_after(self, key: str) -> float:
bucket = self._buckets.get(key)
if bucket is None:
return 0.0
remaining = bucket.locked_until - time.monotonic()
return remaining if remaining > 0 else 0.0
def record_failure(self, key: str) -> None:
now = time.monotonic()
bucket = self._buckets.setdefault(key, _Bucket())
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
bucket.failures = 0
bucket.failures += 1
bucket.last_failure_at = now
if bucket.failures >= self._threshold:
delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold))
bucket.locked_until = now + delay
def record_success(self, key: str) -> None:
self._buckets.pop(key, None)
class AuthRateLimiters:
"""The three throttles B-33 needs, bundled so they can live on `app.state`
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
A module global would persist for the lifetime of the process — fine in
production (one app instance), but wrong in the test suite, where every
test builds its own FastAPI app against a fresh in-memory DB and expects a
clean slate; a shared global would leak failure counts between unrelated
tests. Per-`app.state` state gets a fresh instance per app automatically.
"""
def __init__(self) -> None:
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
+79 -8
View File
@@ -1,11 +1,13 @@
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip as _client_ip
from app.api.errors import http_error
from app.auth.security import create_access_token, hash_password, verify_password
from app.auth.rate_limit import AuthRateLimiters
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
from app.db.models import User
from app.db.session import get_session
from app.wallet.hd import derive_user_address
@@ -15,9 +17,41 @@ router = APIRouter(prefix="/auth", tags=["auth"])
_MAX_REGISTER_RETRIES = 5
def _rate_limiters(request: Request) -> AuthRateLimiters:
# B-33: no rate limiting on login was a brute-forceable path to withdrawing
# someone else's funds. Keyed per-username *and* per-IP so an attacker can't
# dodge the throttle by spraying one password across many accounts, nor by
# routing one account's guesses through many IPs alone (the username key
# still catches that). The IP limiter's threshold is deliberately higher
# than the username one: a single account should lock out fast, but a
# shared/NAT IP hosting several genuine users shouldn't be punished for one
# of them mistyping a password a few times. Registration gets its own,
# coarser limiter, IP-only — no username exists yet to key on — mainly to
# bound how many accounts one IP can spin up (B-31), not to protect a
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
# global so each app instance gets its own, isolated throttle state.
if not hasattr(request.app.state, "auth_rate_limiters"):
request.app.state.auth_rate_limiters = AuthRateLimiters()
return request.app.state.auth_rate_limiters
def _rate_limited_error(retry_after: float):
return http_error(
status.HTTP_429_TOO_MANY_REQUESTS,
"rate_limited",
"too many attempts, try again later",
retry_after_seconds=int(retry_after) + 1,
)
class RegisterRequest(BaseModel):
username: str
password: str
"""Registration used to accept an empty username and a one-character password,
while /users/me/change-password demanded 8 characters — an odd place to be
lenient on a custodial system holding real funds (B-12). MIN_PASSWORD_LENGTH is
shared with that endpoint so the two can't drift apart again."""
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=MIN_PASSWORD_LENGTH, max_length=256)
class TokenResponse(BaseModel):
@@ -29,6 +63,13 @@ class TokenResponse(BaseModel):
async def register(
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
) -> TokenResponse:
limiters = _rate_limiters(request)
ip_key = f"ip:{_client_ip(request)}"
retry_after = limiters.register_ip.retry_after(ip_key)
if retry_after > 0:
raise _rate_limited_error(retry_after)
limiters.register_ip.record_failure(ip_key)
existing = await session.scalar(select(User).where(User.username == body.username))
if existing is not None:
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
@@ -48,12 +89,22 @@ async def register(
session.add(user)
try:
await session.commit()
except IntegrityError:
except IntegrityError as exc:
await session.rollback()
# Only a derivation-index collision is worth retrying. A username
# collision (someone registered the same name between the check above and
# this commit) is permanent, and retrying it five times only to report
# "derivation_index_conflict" told the user the wrong thing entirely (B-12).
if "username" in str(exc.orig).lower():
raise http_error(
status.HTTP_409_CONFLICT, "username_taken", "username already taken"
) from exc
continue
await session.refresh(user)
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
return TokenResponse(
access_token=create_access_token(user.id, user.token_version), address=user.address
)
raise http_error(
status.HTTP_409_CONFLICT,
@@ -68,8 +119,28 @@ class LoginRequest(BaseModel):
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse:
async def login(
body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
) -> TokenResponse:
limiters = _rate_limiters(request)
username_key = f"user:{body.username.lower()}"
ip_key = f"ip:{_client_ip(request)}"
retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key))
if retry_after > 0:
raise _rate_limited_error(retry_after)
user = await session.scalar(select(User).where(User.username == body.username))
if user is None or not verify_password(body.password, user.password_hash):
# Same code path (and therefore the same response) whether the username
# doesn't exist or the password is wrong — no enumeration oracle here.
limiters.login.record_failure(username_key)
limiters.login_ip.record_failure(ip_key)
raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials")
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
# Only the username bucket resets on success — the IP bucket is left to decay
# on its own, so one correct login can't be used to wipe out an IP's failure
# count while it's mid-attack against other accounts.
limiters.login.record_success(username_key)
return TokenResponse(
access_token=create_access_token(user.id, user.token_version), address=user.address
)
+33 -6
View File
@@ -1,11 +1,19 @@
from datetime import datetime, timedelta, timezone
import logging
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from argon2.exceptions import InvalidHashError, VerificationError
from app.config import settings
logger = logging.getLogger(__name__)
# Shared by registration (app/auth/routes.py) and the self-service password change
# (app/api/routes/users.py) so the two can't enforce different minimums.
MIN_PASSWORD_LENGTH = 8
_hasher = PasswordHasher()
@@ -14,18 +22,37 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool:
"""Any failure to verify reads as "wrong password", never as a server error.
Catching only VerifyMismatchError left the other two cases as unhandled 500s
(B-13): VerificationError covers argon2's other verification failures, and
InvalidHashError fires when the stored hash can't be parsed at all — which is a
data problem worth logging, but from the caller's side it still just means this
password does not open this account.
"""
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
except InvalidHashError:
logger.error("stored password hash is unparseable — password verification cannot succeed")
return False
except VerificationError:
return False
def create_access_token(user_id: int) -> str:
def create_access_token(user_id: int, token_version: int = 0) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": str(user_id), "exp": expires_at}
# "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued
# before the account's password was last changed (B-34): change-password and
# the admin reset both bump User.token_version, so every token that still
# carries the old value stops working immediately instead of staying valid
# for up to jwt_expire_minutes after a compromise is supposedly handled.
payload = {"sub": str(user_id), "tv": token_version, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> int:
def decode_access_token(token: str) -> tuple[int, int]:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return int(payload["sub"])
# .get(..., 0) covers tokens issued before "tv" existed (pre-B-34 deploy) —
# they carry no claim at all, and 0 is what a freshly migrated user's
# token_version starts at, so those sessions keep working across the deploy.
return int(payload["sub"]), int(payload.get("tv", 0))
+16 -2
View File
@@ -8,10 +8,24 @@ from app.tx.confirmation import register_handler
async def _on_bet_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
"""Resolved by (round_id, user_id) — the pair is unique per participant and,
unlike the txid, cannot change under us. Keying this on bet_txid meant an
RBF-bumped bet confirmed under a txid no participant carried, so the row stayed
"broadcast" forever and the round could never close (B-02). The txid is kept in
step by tx/broadcast.py too, but correctness here no longer depends on it."""
participant = await session.scalar(
select(RoundParticipant).where(RoundParticipant.bet_txid == pending.current_txid)
select(RoundParticipant).where(
RoundParticipant.round_id == pending.round_id,
RoundParticipant.user_id == pending.user_id,
)
)
if participant is not None and participant.status == "broadcast":
if participant is None:
# Fall back to the txid for rows written before this changed, and for any
# pending row missing its round/user link.
participant = await session.scalar(
select(RoundParticipant).where(RoundParticipant.bet_txid == pending.current_txid)
)
if participant is not None and participant.status in ("building", "broadcast"):
participant.status = "confirmed"
participant.confirmed_at = datetime.now(timezone.utc)
+66 -8
View File
@@ -62,14 +62,19 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise BetError(exc.code, str(exc)) from exc
await client.broadcast(built.raw_hex)
raise BetError(exc.code, str(exc), **exc.params) from exc
# --- Phase 1: record the intent, *then* broadcast (B-08) --------------------
# Broadcasting first meant a failure (or a crash) between the broadcast and the
# commit left the coins irreversibly spent on-chain with no trace in the DB: no
# participant, so no entry in the draw; no pending row, so no RBF and no
# confirmation tracking; and the UTXOs not even marked spent, so the next bet
# would try to double-spend them. Writing "building" rows first means the worst
# case is a row the reconciler (app/tx/reconcile.py) can resolve either way by
# asking the chain whether the tx exists.
spent_by_key = {(u.txid, u.vout): u for u in unspent}
for spent in built.spent_utxos:
row = spent_by_key[(spent.txid, spent.vout)]
row.spent_txid = built.txid
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
await recompute_balance(session, user.id)
broadcast_at = datetime.now(timezone.utc)
@@ -79,10 +84,25 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
bet_amount_sats=built.recipient_sats,
bet_txid=built.txid,
broadcast_at=broadcast_at,
status="broadcast",
status="building",
)
session.add(participant)
session.add(_pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb))
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
session.add(pending)
await session.commit()
# --- Phase 2: broadcast, then promote both rows to their live state ---------
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
# The node refused it (fee too low, dust, mempool conflict, or simply an
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
# give the user a translatable failure instead of a bare 500 (B-07).
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
participant.status = "broadcast"
pending.status = "pending"
await write_audit_log(
session,
"bet_placed",
@@ -97,6 +117,40 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
return participant
async def _release_failed_bet(
session: AsyncSession,
participant: RoundParticipant,
pending: PendingTransaction,
built: BuiltTransaction,
user_id: int,
reason: str,
) -> None:
"""Undo phase 1 after a failed broadcast: free the UTXOs the build reserved, drop
the two rows, and restore the balance. Same shape as what the reconciler does for
a tx that turns out never to have made it onto the chain."""
for spent in built.spent_utxos:
row = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
)
if row is not None:
row.spent_txid = None
await session.delete(participant)
await session.delete(pending)
await recompute_balance(session, user_id)
await write_audit_log(
session,
"bet_broadcast_failed",
{"txid": built.txid, "reason": reason[:200]},
user_id=user_id,
)
await session.commit()
# The rollback moved as much state as the successful path did — the balance is
# back, the participant is gone, so participant_count and jackpot shrank again.
# Without this the dashboards kept showing the phantom bet until their next poll
# (B-49); the reconciler's own abandon path has always published here.
broadcaster.publish()
def _pending_transaction(
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
) -> PendingTransaction:
@@ -107,5 +161,9 @@ def _pending_transaction(
current_txid=built.txid,
fee_rate_sat_vb=fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
# "building" until the broadcast succeeds — see place_bet's two phases. It
# matters which one this starts as: the reconciler gives a "building" row a
# short grace period (we may have died mid-broadcast) and a "pending" one a
# long one (a node accepted it once, so it deserves the RBF attempts first).
status="building",
)
+54
View File
@@ -1,5 +1,10 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
# than being shipped by accident.
MIN_JWT_SECRET_LENGTH = 32
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -9,6 +14,13 @@ class Settings(BaseSettings):
electrum_host: str = "santantonio.sytes.net"
electrum_port: int = 50002
electrum_use_ssl: bool = True
# Additional servers to fall back to, comma-separated `host:port[:notls]`.
# The listener rotates over primary + these (app/electrum/listener.py), so one
# unreachable server costs a single reconnect attempt instead of an outage:
# every deposit credit, broadcast and confirmation goes through this one
# connection, which makes a single hardcoded server the platform's biggest
# single point of failure. Parsed by electrum.client.parse_endpoints.
electrum_fallback_servers: str = ""
xprv_encryption_key: str = ""
master_key_path: str = "./master.xprv.enc"
@@ -17,6 +29,11 @@ class Settings(BaseSettings):
jwt_expire_minutes: int = 60 * 24
admin_token: str = ""
# Swagger/ReDoc/OpenAPI JSON expose the entire API surface (admin endpoints
# included) to anyone who requests them. Off by default (B-42) — set to true
# only for local development, never in production.
enable_api_docs: bool = False
# Every business/round parameter (bet amount, round duration/cooldown,
# min amount, fee rate, RBF timeout, fee address) lives in the round_config
# DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) —
@@ -25,3 +42,40 @@ class Settings(BaseSettings):
settings = Settings()
class ConfigError(Exception):
"""A misconfiguration serious enough that the app must refuse to serve."""
def validate_runtime_secrets(config: Settings | None = None) -> None:
"""Fail fast on secrets that would otherwise only break at first use: an empty
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
the container comes up looking healthy and breaks the moment a real user
touches it.
Called from the app's lifespan (app/main.py) rather than as a Settings
field_validator on purpose: Settings is constructed at import time by every
module that reads config, including the test suite, which has no .env and no
business holding real secrets. At startup the guarantee still holds where it
matters — the server refuses to serve half-configured — without coupling every
import to a gitignored file.
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
request when it's empty, so the effect is a locked admin panel, not an open one.
"""
config = config or settings
problems = []
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
problems.append(
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
)
if not config.xprv_encryption_key.strip():
problems.append(
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
)
if problems:
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
+36 -1
View File
@@ -1,9 +1,44 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# How long a writer waits for a lock held by another writer before SQLite raises
# "database is locked" (B-39). A few seconds is enough to ride out this app's own
# five concurrent background tasks (scheduler, confirmation poller, RBF bumper,
# two reconcilers) plus HTTP handlers briefly overlapping a write.
_SQLITE_BUSY_TIMEOUT_MS = 5000
def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
"""Without WAL, SQLite's default (rollback-journal) mode lets a writer block
every reader for the duration of its transaction, and a second writer arriving
while one is already active fails immediately rather than waiting at all —
realistic under this app's concurrency, and nothing previously handled it.
WAL lets readers and writers proceed without blocking each other, and
busy_timeout gives a second writer a real window to wait for the first
instead of an instant `OperationalError`.
No-op for any dialect other than sqlite (e.g. a future PostgreSQL
DATABASE_URL), which neither needs nor understands these pragmas.
"""
if target_engine.dialect.name != "sqlite":
return
@event.listens_for(target_engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
finally:
cursor.close()
engine = create_async_engine(settings.database_url)
_register_sqlite_pragmas(engine)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+57 -4
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint
from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -21,6 +21,12 @@ class User(Base):
# 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)
@@ -39,13 +45,38 @@ class UtxoEvent(Base):
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)
# 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)
@@ -102,7 +133,17 @@ class RoundConfig(Base):
class PendingTransaction(Base):
"""Single source of truth for the RBF timeout->bump->rebroadcast loop."""
"""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"
@@ -113,11 +154,23 @@ class PendingTransaction(Base):
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)
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)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
@@ -140,7 +193,7 @@ class AuditLog(Base):
id: Mapped[int] = mapped_column(primary_key=True)
event_type: Mapped[str] = mapped_column(String(32))
payload_json: Mapped[str] = mapped_column(String)
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)
+69
View File
@@ -0,0 +1,69 @@
"""Periodic safety net for deposit crediting and external-spend detection (B-30),
independent of scripthash-change notifications.
Those notifications are the fast path, but nothing else re-verifies a user's
balance against the chain if one is ever silently lost: `address_for_new_user`'s
subscribe is best-effort (its own failure just logs, see electrum/listener.py),
and on an otherwise healthy, long-lived connection there may be no reconnect for
days — the only other event that re-subscribes everyone from scratch. Without
this, a single lost subscription meant that user's deposits were never credited,
indefinitely.
This mirrors app/tx/reconcile.py's shape (a periodic sweep gated on the Electrum
client being connected) but reuses ElectrumListener.refresh_user directly rather
than re-implementing crediting/spend-detection, so the notification-driven and
periodic paths can never behave differently from each other.
"""
import asyncio
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db.models import User
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
logger = logging.getLogger(__name__)
_SWEEP_INTERVAL_SECONDS = 300
class DepositReconciler:
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
self._session_factory = session_factory
self._listener = listener
async def run(self) -> None:
while True:
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
if self._listener.client is None:
continue
try:
await self._sweep_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("deposit reconciliation sweep failed")
async def _sweep_once(self) -> None:
"""Round-robins over every user's address rather than only ones missing
from the listener's in-memory `_scripthash_to_user` map: that map can't
tell "never subscribed" apart from "subscribed, but this server silently
stopped delivering notifications for it" — exactly the failure mode this
exists to catch. One user failing (a transient network hiccup) must not
stop the sweep from reaching the rest, mirroring poll_once's per-item
isolation in tx/confirmation.py.
"""
async with self._session_factory() as session:
users = (await session.scalars(select(User))).all()
for user in users:
if self._listener.client is None:
return # connection dropped mid-sweep; the next reconnect's own _subscribe_all_users covers everyone
scripthash = address_to_scripthash(user.address)
try:
await self._listener.refresh_user(user.id, scripthash)
except Exception:
logger.exception("deposit reconciliation failed for user_id=%s", user.id)
+119
View File
@@ -1,3 +1,5 @@
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,6 +8,8 @@ from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
@@ -54,3 +58,118 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
broadcaster.publish() # nudges this user's dashboard to refetch its balance instantly
return newly_credited
_EXTERNAL_SPEND_SENTINEL = "external-spend"
async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""The reverse of a mark applied by find_utxos_missing_from/
mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as
spent outside the platform reappears as unspent in a later listunspent, undo
the mark instead of leaving it permanent no matter what the chain says
afterwards. Cheap and purely DB-side — always safe to run on every refresh.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
marked_rows = (
await session.scalars(
select(UtxoEvent).where(
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
)
)
).all()
reinstated = 0
for row in marked_rows:
if (row.txid, row.vout) not in current_keys:
continue
row.spent_txid = None
await write_audit_log(
session,
"utxo_external_spend_reinstated",
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
reinstated += 1
if reinstated:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return reinstated
async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: list[dict]) -> list[UtxoEvent]:
"""Candidates for an external spend (B-29): unspent UTXOs the DB believes this
user still holds that are absent from `entries`, this address's current
listunspent. Everything the platform itself spends (bets, withdrawals,
payouts) sets spent_txid at broadcast time, before the tx ever reaches the
chain — so an outpoint still marked unspent in our own DB that Electrum no
longer reports as unspent was never on our own radar.
Returning a row here is *not* proof it was actually spent — only that this one
server's reply no longer lists it. A single broken, behind, or malicious
server could otherwise zero a user's balance on one bad reply, which is why
the caller (electrum/listener.py:refresh_user) must independently
corroborate each candidate against other configured servers before treating
it as genuine, rather than this function marking anything itself.
An entirely empty `entries` for an address the DB believes is funded returns
no candidates at all: it would otherwise flag every one of this user's UTXOs
as missing from a single reply, which is a strong sign of an incomplete or
broken response rather than N independent spends landing in the same refresh.
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
unspent_rows = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
)
).all()
if not entries and unspent_rows:
logger.warning(
"listunspent for user_id=%s returned no entries at all while %s UTXO(s) are still recorded "
"unspent — treating this as an incomplete response rather than a full external sweep",
user_id,
len(unspent_rows),
)
return []
return [row for row in unspent_rows if (row.txid, row.vout) not in current_keys]
async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_ids: list[int]) -> int:
"""Applies the external-spend sentinel to UTXOs the caller has already
corroborated against other servers (B-29) — this function does no
verification of its own, only persistence, so it never runs with a session
held open across the network calls that verification needs.
Re-checks each row is still unspent before applying the mark: something else
may have resolved it (a legitimate platform spend, or a prior refresh) between
when the caller read the candidate list and finished corroborating it.
"""
marked = 0
for utxo_id in utxo_ids:
row = await session.get(UtxoEvent, utxo_id)
if row is None or row.spent_txid is not None:
continue
row.spent_txid = _EXTERNAL_SPEND_SENTINEL
await write_audit_log(
session,
"utxo_spent_externally",
{"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats},
user_id=user_id,
)
marked += 1
if marked:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
broadcaster.publish()
return marked
+116 -6
View File
@@ -2,6 +2,67 @@ import asyncio
import itertools
import json
import ssl
from typing import NamedTuple
class ElectrumEndpoint(NamedTuple):
"""One server to connect to. The listener rotates over a list of these so a
single unreachable or misbehaving server doesn't take the platform down —
every consumer of PLM chain data goes through this one connection."""
host: str
port: int
use_ssl: bool = True
def __str__(self) -> str:
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}"
def parse_endpoints(
primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str
) -> list[ElectrumEndpoint]:
"""Build the connection rotation: the primary server first, then whatever
ELECTRUM_FALLBACK_SERVERS lists.
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
case) or `host:port:notls`. Malformed entries raise ValueError rather than
being skipped: a typo in a fallback server is something to fix at startup,
not to discover during an outage, when the fallback is what's needed.
Duplicates are dropped, keeping first position.
"""
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)]
for raw in fallback_spec.split(","):
entry = raw.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) not in (2, 3):
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
host, port = parts[0].strip(), parts[1].strip()
if not host or not port.isdigit():
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
use_ssl = True
if len(parts) == 3:
flag = parts[2].strip().lower()
if flag not in ("ssl", "tls", "notls", "plain"):
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
use_ssl = flag in ("ssl", "tls")
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl))
deduped: list[ElectrumEndpoint] = []
for endpoint in endpoints:
if endpoint not in deduped:
deduped.append(endpoint)
return deduped
# Every request is bounded: without this, a half-open socket (the peer vanished
# without a FIN, or the read loop died — see _read_loop) leaves `await future`
# hanging forever, and with it whatever was awaiting the call. That used to be
# unbounded, which meant a POST /bets could hang while holding the per-user lock
# and the confirmation poller could stop polling permanently.
_REQUEST_TIMEOUT_SECONDS = 15
class ElectrumError(Exception):
@@ -15,6 +76,11 @@ class ElectrumClient:
arrive under the *same* method name as the subscribe call, multiplexed for every
scripthash subscribed — callers read `notifications(method)` and, for scripthash
pushes, dispatch on `params[0]` (the scripthash) themselves.
A dead connection is observable rather than silent: `wait_closed()` resolves as
soon as the read loop terminates for any reason, which is what lets
ElectrumListener notice the drop and reconnect instead of waiting forever on
notification queues nobody will ever fill again.
"""
def __init__(self, host: str, port: int, use_ssl: bool = True):
@@ -27,6 +93,7 @@ class ElectrumClient:
self._pending: dict[int, asyncio.Future] = {}
self._subscriptions: dict[str, asyncio.Queue] = {}
self._read_task: asyncio.Task | None = None
self._closed = asyncio.Event()
async def connect(self) -> None:
# Electrum servers commonly present self-signed certs; the protocol's trust
@@ -42,25 +109,51 @@ class ElectrumClient:
await self.request("server.version", ["plm-lottery", "1.4"])
async def close(self) -> None:
self._closed.set()
if self._read_task is not None:
self._read_task.cancel()
if self._writer is not None:
self._writer.close()
try:
await asyncio.wait_for(self._writer.wait_closed(), timeout=2)
except (ssl.SSLError, TimeoutError, asyncio.TimeoutError):
except (ssl.SSLError, TimeoutError, asyncio.TimeoutError, ConnectionError):
pass # some Electrum servers don't send a clean TLS close_notify
async def wait_closed(self) -> None:
"""Resolves once this connection is gone — read loop finished (peer closed,
protocol error, TLS failure) or close() was called. ElectrumListener races
this against its notification consumers so a drop triggers a reconnect."""
await self._closed.wait()
async def request(self, method: str, params: list | None = None) -> object:
if self._writer is None:
if self._writer is None or self._closed.is_set():
raise ElectrumError("not connected")
request_id = next(self._id_counter)
future: asyncio.Future = asyncio.get_event_loop().create_future()
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[request_id] = future
payload = json.dumps({"id": request_id, "method": method, "params": params or []}) + "\n"
self._writer.write(payload.encode())
await self._writer.drain()
return await future
try:
self._writer.write(payload.encode())
await self._writer.drain()
except (ConnectionError, ssl.SSLError, OSError) as exc:
self._pending.pop(request_id, None)
self._closed.set()
raise ElectrumError(f"write failed: {exc}") from exc
try:
return await asyncio.wait_for(future, timeout=_REQUEST_TIMEOUT_SECONDS)
except (TimeoutError, asyncio.TimeoutError) as exc:
self._pending.pop(request_id, None)
# A server that owes us a reply and never sends one is indistinguishable
# from a dead socket, and retrying on the same connection would keep
# hitting it — tear it down so the listener reconnects.
self._closed.set()
raise ElectrumError(f"{method} timed out after {_REQUEST_TIMEOUT_SECONDS}s") from exc
async def ping(self) -> None:
"""Keepalive: Electrum servers drop idle connections (commonly after ~10
minutes), which on a quiet instance would otherwise be the normal way this
connection dies. Called periodically by ElectrumListener."""
await self.request("server.ping")
def notifications(self, method: str) -> asyncio.Queue:
return self._subscriptions.setdefault(method, asyncio.Queue())
@@ -76,6 +169,17 @@ class ElectrumClient:
async def listunspent(self, scripthash: str) -> list[dict]:
return await self.request("blockchain.scripthash.listunspent", [scripthash])
async def get_history(self, scripthash: str) -> list[dict]:
"""Every transaction touching `scripthash`, each as {"tx_hash", "height"} —
height > 0 means confirmed at that height, height <= 0 means still in the
mempool. Used instead of blockchain.transaction.get's verbose=True mode
for confirmation/existence checks (B-41): several Electrum server
implementations and versions reject the verbose flag outright ("verbose
transactions are currently unsupported"), while get_history is a plain,
universally-supported method every server must implement.
"""
return await self.request("blockchain.scripthash.get_history", [scripthash])
async def broadcast(self, raw_tx_hex: str) -> str:
return await self.request("blockchain.transaction.broadcast", [raw_tx_hex])
@@ -92,6 +196,12 @@ class ElectrumClient:
message = json.loads(line)
self._dispatch(message)
finally:
# Whatever ended this loop — clean EOF, protocol error, TLS failure — the
# connection is unusable from here on. Setting this is what makes the
# death observable to wait_closed(), and so to the listener's reconnect
# logic; without it the listener waited on notification queues nobody
# would ever fill again, forever (B-01).
self._closed.set()
error = ElectrumError("connection closed")
for future in self._pending.values():
if not future.done():
+352 -35
View File
@@ -6,95 +6,386 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
from app.electrum.client import ElectrumClient
from app.deposits.service import (
credit_confirmed_utxos,
find_utxos_missing_from,
mark_utxos_spent_externally,
reinstate_reappeared_utxos,
)
from app.electrum.client import ElectrumClient, ElectrumEndpoint
from app.electrum.scripthash import address_to_scripthash
from app.rounds.draw import (
HeaderValidationError,
header_hex_to_block_hash,
header_meets_its_own_target,
header_prev_hash,
)
from app.rounds.events import broadcaster
logger = logging.getLogger(__name__)
# How often to ping the server on an otherwise idle connection. Electrum servers
# commonly drop idle clients after ~10 minutes, so on a quiet instance this is the
# difference between noticing the drop in a minute and never noticing it at all.
_PING_INTERVAL_SECONDS = 60
# How long to wait for any *one* other server's answer when corroborating the
# draw's block header (B-28) or a candidate external spend (B-29). Shorter than
# the standard request timeout since this is a supplementary check across several
# servers at once — a single slow fallback shouldn't hold up the others.
_CORROBORATION_TIMEOUT_SECONDS = 10
# How many users to resubscribe at once on reconnect (B-31), instead of one at a
# time. Bounded rather than unlimited so a huge user base doesn't open thousands
# of simultaneous in-flight requests against the one active connection.
_RESUBSCRIBE_CONCURRENCY = 20
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
every user's address (plus any address added later via add_address), and
credits confirmed deposits as scripthash-change notifications arrive.
Reconnects with backoff on any failure; a fresh connection re-subscribes to
every user pulled straight from the DB, so no in-memory subscription state is
ever a stale source of truth.
Reconnects on any failure; a fresh connection re-subscribes to every user
pulled straight from the DB, so no in-memory subscription state is ever a
stale source of truth.
Connections rotate over `endpoints`: after a failed or dropped session the
next server in the list is tried immediately, and only once every server has
had a turn does the backoff sleep kick in. That way a single dead server costs
one attempt rather than an outage, while a genuinely offline network (all
servers down) still backs off instead of spinning.
"""
def __init__(self, client_factory: Callable[[], ElectrumClient], session_factory: async_sessionmaker):
def __init__(
self,
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
session_factory: async_sessionmaker,
endpoints: list[ElectrumEndpoint] | None = None,
):
self._client_factory = client_factory
self._session_factory = session_factory
self._endpoints = list(endpoints or [])
self._endpoint_index = 0
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
# can't be garbage-collected mid-flight, and so its exception (if any) is
# actually observed instead of only reaching asyncio's default "Task
# exception was never retrieved" handler (B-30).
self._background_tasks: set[asyncio.Task] = set()
self.tip_height: int = 0
self.tip_header_hex: str | None = None
self.client: ElectrumClient | None = None
@property
def current_endpoint(self) -> ElectrumEndpoint | None:
"""Which server the next (or current) session uses — for logging and for
the admin dashboard's connection status."""
if not self._endpoints:
return None
return self._endpoints[self._endpoint_index]
def address_for_new_user(self, user_id: int, address: str) -> None:
"""Called right after a user registers so their deposit address starts
being watched immediately, without waiting for the next reconnect cycle."""
being watched immediately, without waiting for the next reconnect cycle.
Best-effort, not retried on its own: `self.client` can still become None
between the check below and the task actually running (the connection
drops in between), which used to raise an AssertionError inside an
untracked task and vanish silently (B-30). The exception is now logged
instead, and — since a failure here just means this one address stays
unsubscribed until the next reconnect's `_subscribe_all_users` or the
periodic `DepositReconciler` sweep (also B-30) catches it — that's an
acceptable, self-healing outcome rather than something worth its own
retry/backoff loop.
"""
scripthash = address_to_scripthash(address)
self._scripthash_to_user[scripthash] = user_id
if self.client is not None:
asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id))
task = asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
task.add_done_callback(self._log_subscribe_task_failure)
def _log_subscribe_task_failure(self, task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.warning("could not subscribe a newly-registered user's address: %r", exc)
async def run(self) -> None:
backoff = 1
failures_this_cycle = 0
while True:
endpoint = self.current_endpoint
if endpoint is None:
logger.error("no Electrum endpoints configured; listener idle")
return
connected = False
try:
await self._run_once()
connected = await self._run_once(endpoint)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Electrum listener error, reconnecting in %ss", backoff)
logger.exception("Electrum session on %s failed", endpoint)
finally:
self.client = None
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
backoff = 1
async def _run_once(self) -> None:
client = self._client_factory()
# Move on to the next server regardless of why this session ended: a
# server that just dropped us has no claim on being tried first again.
if len(self._endpoints) > 1:
self._endpoint_index = (self._endpoint_index + 1) % len(self._endpoints)
if connected:
# We did reach a server, so the network is up — don't let an earlier
# streak of failures keep penalizing the next attempt.
backoff = 1
failures_this_cycle = 0
logger.info("Electrum connection to %s ended, reconnecting", endpoint)
continue
failures_this_cycle += 1
if failures_this_cycle < len(self._endpoints):
continue # other servers untried — go straight to the next one
failures_this_cycle = 0
logger.warning("all %s Electrum server(s) unreachable, retrying in %ss", len(self._endpoints), backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
async def _run_once(self, endpoint: ElectrumEndpoint) -> bool:
"""One connection's whole lifetime. Returns True if the connection was
actually established (so the caller knows the network is reachable and can
reset its backoff), False if it never got that far."""
client = self._client_factory(endpoint)
await client.connect()
self.client = client
logger.info("Electrum connected to %s", endpoint)
header = await client.subscribe_headers()
self.tip_height = header["height"]
self.tip_header_hex = header.get("hex")
await self._subscribe_all_users()
headers_queue = client.notifications("blockchain.headers.subscribe")
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
try:
await asyncio.gather(
self._consume_headers(headers_queue),
self._consume_scripthash(scripthash_queue),
)
header = await client.subscribe_headers()
self._apply_header(header)
headers_queue = client.notifications("blockchain.headers.subscribe")
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
# The consumers below block on their queues forever by design, so they
# can never notice the connection dying — client.wait_closed() and the
# keepalive are what make the drop observable. Whichever finishes first
# ends the session and sends run() around to the next server.
tasks = [
asyncio.create_task(self._consume_headers(headers_queue)),
asyncio.create_task(self._consume_scripthash(scripthash_queue)),
asyncio.create_task(self._keepalive(client)),
asyncio.create_task(client.wait_closed()),
]
# B-31: resubscribing every user is O(users) sequential round-trips —
# at thousands of users that's minutes during which, previously,
# nothing above had started yet: tip_height was frozen and an
# in-flight draw's _wait_for_next_block made zero progress for the
# entire resubscribe. Running it as its own background task instead
# of awaiting it inline here means tip updates (and notifications for
# whichever users are already subscribed) keep flowing throughout.
# It's deliberately not one of the raced `tasks` above: unlike those,
# it's expected to finish normally, and its own completion must not
# look like the session ending. Any failure partway through is
# logged the same way address_for_new_user's background task is
# (B-30), and it's cancelled below along with everything else once
# the session actually does end.
subscribe_task = asyncio.create_task(self._subscribe_all_users())
subscribe_task.add_done_callback(self._log_subscribe_all_users_failure)
try:
done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
finally:
subscribe_task.cancel()
for task in tasks:
task.cancel()
for task in done:
exc = task.exception()
if exc is not None:
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
finally:
self.client = None
await client.close()
return True
def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.warning("resubscribing all users failed partway through: %r", exc)
async def _keepalive(self, client: ElectrumClient) -> None:
while True:
await asyncio.sleep(_PING_INTERVAL_SECONDS)
await client.ping() # raises (and so ends the session) on timeout or a dead socket
async def _subscribe_all_users(self) -> None:
"""B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at
a time) instead of one user at a time — at thousands of users a serial
loop meant thousands of sequential round-trips. One user's failure (a
single slow or briefly-erroring request) must not stop the rest from
being subscribed, mirroring the same per-item isolation used elsewhere
(e.g. tx/confirmation.py's poll_once, deposits/reconcile.py's sweep)."""
async with self._session_factory() as session:
users = (await session.scalars(select(User))).all()
for user in users:
semaphore = asyncio.Semaphore(_RESUBSCRIBE_CONCURRENCY)
async def _subscribe_one(user: User) -> None:
scripthash = address_to_scripthash(user.address)
self._scripthash_to_user[scripthash] = user.id
await self._subscribe_and_refresh(scripthash, user.id)
async with semaphore:
try:
await self._subscribe_and_refresh(scripthash, user.id)
except Exception:
logger.exception("failed to resubscribe user_id=%s", user.id)
await asyncio.gather(*(_subscribe_one(user) for user in users))
async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None:
assert self.client is not None
await self.client.subscribe_scripthash(scripthash)
await self._refresh_user(user_id, scripthash)
await self.refresh_user(user_id, scripthash)
def _apply_header(self, header: dict) -> None:
"""Record a new chain tip, refusing to move backwards.
Full reorg handling is out of scope for v1 by explicit design decision, but
the tip must never regress: `_wait_for_next_block` waits for
`tip_height > tip_at_close`, so a lower height would silently add a block to
the draw's wait. height and hex are applied together or not at all —
applying a losing header's hex would leave tip_height and tip_header_hex
describing different blocks, and that hex is the draw's entropy source.
Two validation checks guard against a hostile or MITM'd server simply
fabricating a header (B-28), since that header is the draw's sole source of
entropy: it must satisfy the difficulty target it claims for itself, and —
when it's a direct single-block advance from our own current tip, the only
case we can check without a full header chain — it must chain from that
tip's hash. Either failure raises HeaderValidationError rather than
silently ignoring the header, which (via _consume_headers/_run_once) ends
this session the same way a dropped connection would, so run() rotates to
the next configured server instead of continuing to trust this one.
"""
height = header["height"]
header_hex = header.get("hex")
if height < self.tip_height:
logger.warning(
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
height,
self.tip_height,
)
return
if header_hex:
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError(
f"header at height {height} does not satisfy its own claimed difficulty target"
)
if (
self.tip_header_hex
and height == self.tip_height + 1
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
):
raise HeaderValidationError(
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
)
self.tip_height = height
self.tip_header_hex = header_hex
async def _corroborate_majority(
self,
ask: Callable[[ElectrumEndpoint], "asyncio.Future"],
agrees: Callable[[object], bool],
description: str,
) -> bool:
"""Shared quorum logic behind corroborate_header (B-28) and
corroborate_utxo_spent (B-29): ask every *other* configured server (never
the currently active one — that's exactly what a hostile server or a MITM
would control) and require a strict majority of the ones that actually
answer to agree, via `agrees`, with what our own connection reported.
Returns True with no other servers configured — nothing to corroborate
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
(see CLAUDE.md). Returns False (never silently "passes") if none of the
others could be reached, since an unreachable network proves nothing
either way.
"""
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
if not others:
return True
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
responded = [result for result in results if result is not None]
if not responded:
logger.warning(
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
)
return False
agreements = sum(1 for result in responded if agrees(result))
return agreements * 2 > len(responded)
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
"""B-28: is `expected_hash` — the header our own active connection
reported for `height` — corroborated by other configured servers before
the draw (rounds/scheduler.py:_wait_for_next_block) treats it as
trustworthy entropy? Without this, a single hostile server (or a MITM on
the one active connection) can single-handedly decide who wins every
round; this raises the bar to controlling a majority of the configured
servers. See _corroborate_majority for the shared quorum logic.
"""
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
result = await asyncio.wait_for(
client.request("blockchain.block.header", [height]),
timeout=_CORROBORATION_TIMEOUT_SECONDS,
)
if not isinstance(result, str):
return None
return header_hex_to_block_hash(result)
except Exception:
return None
finally:
await client.close()
return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header")
async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool:
"""B-29: before deposits/service.py's find_utxos_missing_from candidates
are treated as genuinely spent outside the platform, ask the other
configured servers whether *they* also no longer report this outpoint as
unspent. A single broken, behind, or malicious server could otherwise zero
a user's balance on one incomplete listunspent reply. See
_corroborate_majority for the shared quorum logic.
"""
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
entries = await asyncio.wait_for(
client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS
)
still_unspent = any(e.get("tx_hash") == txid and e.get("tx_pos") == vout for e in entries)
return not still_unspent # True = this server agrees the outpoint is gone
except Exception:
return None
finally:
await client.close()
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
async def _consume_headers(self, queue: asyncio.Queue) -> None:
while True:
params = await queue.get()
for header in params:
self.tip_height = header["height"]
self.tip_header_hex = header.get("hex")
self._apply_header(header)
# A new block is exactly what the "drawing" phase is waiting on
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
# refetch instead of waiting for their next poll.
@@ -105,12 +396,38 @@ class ElectrumListener:
scripthash, _status = await queue.get()
user_id = self._scripthash_to_user.get(scripthash)
if user_id is not None:
await self._refresh_user(user_id, scripthash)
await self.refresh_user(user_id, scripthash)
async def _refresh_user(self, user_id: int, scripthash: str) -> None:
async def refresh_user(self, user_id: int, scripthash: str) -> None:
"""Three phases, so no DB session is held across a network call (B-18),
same shape as _trigger_payout: read what's needed, corroborate any
candidate external spends against other servers (B-29), then persist.
"""
assert self.client is not None
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
candidates = [
(row.id, row.txid, row.vout)
for row in await find_utxos_missing_from(session, user_id, entries)
]
confirmed_ids = [
utxo_id
for utxo_id, txid, vout in candidates
if await self.corroborate_utxo_spent(scripthash, txid, vout)
]
spent_externally = 0
if confirmed_ids:
async with self._session_factory() as session:
spent_externally = await mark_utxos_spent_externally(session, user_id, confirmed_ids)
if credited:
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
if reinstated:
logger.info("reinstated %s previously-flagged UTXO(s) for user_id=%s", reinstated, user_id)
if spent_externally:
logger.warning("%s UTXO(s) spent outside the platform for user_id=%s", spent_externally, user_id)
+52 -12
View File
@@ -2,7 +2,7 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -20,37 +20,60 @@ from app.api.routes.rounds import router as rounds_router
from app.api.routes.users import router as users_router
from app.api.routes.withdrawals import router as withdrawals_router
from app.auth.routes import router as auth_router
from app.config import settings
from app.api.errors import ApiError
from app.config import settings, validate_runtime_secrets
from app.db.base import AsyncSessionLocal
from app.electrum.client import ElectrumClient
from app.deposits.reconcile import DepositReconciler
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
from app.electrum.listener import ElectrumListener
from app.rounds.scheduler import RoundScheduler
from app.tx.broadcast import RbfBumper
from app.tx.confirmation import ConfirmationPoller
from app.tx.locks import UserLocks
from app.tx.reconcile import PendingTransactionReconciler
logger = logging.getLogger(__name__)
def _make_electrum_client() -> ElectrumClient:
return ElectrumClient(settings.electrum_host, settings.electrum_port, settings.electrum_use_ssl)
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
@asynccontextmanager
async def lifespan(app: FastAPI):
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal)
# Refuses to serve rather than starting up half-configured (B-15).
validate_runtime_secrets()
endpoints = parse_endpoints(
settings.electrum_host,
settings.electrum_port,
settings.electrum_use_ssl,
settings.electrum_fallback_servers,
)
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints)
app.state.electrum_listener = listener
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
# Resolves in-flight transactions against the chain — the piece that lets the
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client)
# Periodic safety net for deposit crediting/external-spend detection,
# independent of scripthash-change notifications — catches a subscription
# silently lost on an otherwise healthy connection (B-30).
deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener)
tasks = [
asyncio.create_task(listener.run()),
asyncio.create_task(scheduler.run()),
asyncio.create_task(poller.run()),
asyncio.create_task(bumper.run()),
asyncio.create_task(reconciler.run()),
asyncio.create_task(deposit_reconciler.run()),
]
try:
yield
@@ -61,7 +84,16 @@ async def lifespan(app: FastAPI):
await listener.client.close()
app = FastAPI(title="PLM Lottery", lifespan=lifespan)
# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin
# endpoints included, to anyone who requests them (B-42) — disabled unless
# ENABLE_API_DOCS is explicitly set, which should only happen in development.
app = FastAPI(
title="PLM Lottery",
lifespan=lifespan,
docs_url="/docs" if settings.enable_api_docs else None,
redoc_url="/redoc" if settings.enable_api_docs else None,
openapi_url="/openapi.json" if settings.enable_api_docs else None,
)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(bets_router)
@@ -73,8 +105,14 @@ app.include_router(rounds_router)
@app.exception_handler(Exception)
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
"""Answers with the same structured `detail` shape as every deliberate failure
(app/api/errors.py) so clients never have to special-case unexpected errors.
The exception itself stays in logs/app.log only — never in the response body."""
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "internal server error"})
return JSONResponse(
status_code=500,
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
)
@app.get("/health")
@@ -97,10 +135,12 @@ async def admin_panel() -> FileResponse:
@app.get("/guida", include_in_schema=False)
async def user_guide() -> FileResponse:
"""Serves docs/guida-utente.md as plain text so it opens inline in the
browser (no markdown rendering — keeps this simple), linked from the
navbar's help button in app/static/index.html."""
return FileResponse("docs/guida-utente.md", media_type="text/plain; charset=utf-8")
return FileResponse("app/static/guida.html", headers=_NO_STORE_HEADERS)
@app.get("/report-bug", include_in_schema=False)
async def report_bug_page() -> FileResponse:
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
+7 -1
View File
@@ -8,7 +8,13 @@ from app.tx.confirmation import register_handler
async def _on_payout_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
# Resolved by round_id rather than by payout_txid: an RBF-bumped payout confirms
# under a different txid than the one first recorded (B-02).
round_ = None
if pending.round_id is not None:
round_ = await session.get(Round, pending.round_id)
if round_ is None:
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
if round_ is not None and round_.status == "paying_out":
round_.status = "closed"
# closed_at is what round_cooldown_seconds counts from (service.py's
+67
View File
@@ -1,5 +1,22 @@
import hashlib
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
_HEADER_LENGTH_BYTES = 80
_PREV_BLOCK_OFFSET = 4
_PREV_BLOCK_LENGTH = 32
_BITS_OFFSET = 72
_BITS_LENGTH = 4
class HeaderValidationError(Exception):
"""Raised by ElectrumListener._apply_header (B-28) when a header either doesn't
satisfy the difficulty target it claims for itself, or doesn't chain from the
previously accepted tip. Letting this propagate out of the header-consuming
task ends the current Electrum session the same way a dropped connection would
(see ElectrumListener._run_once), so the listener rotates to the next
configured server instead of trusting a header a server just forged."""
def header_hex_to_block_hash(header_hex: str) -> str:
"""Block hash from a raw Electrum header: sha256d, byte-reversed, hex.
@@ -10,6 +27,56 @@ def header_hex_to_block_hash(header_hex: str) -> str:
return digest[::-1].hex()
def header_prev_hash(header_hex: str) -> str:
"""The header's `prev_block` field, byte-reversed to the same conventional
(display) order as header_hex_to_block_hash's return value, so the two can be
compared directly to check that one header actually chains from another."""
header_bytes = bytes.fromhex(header_hex)
prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH]
return prev[::-1].hex()
def _target_from_bits(bits: int) -> int:
"""Decompress Bitcoin-style compact `nBits` difficulty encoding into the full
256-bit target a valid header's hash must be less than or equal to."""
exponent = bits >> 24
mantissa = bits & 0xFFFFFF
if exponent <= 3:
return mantissa >> (8 * (3 - exponent))
return mantissa << (8 * (exponent - 3))
def header_meets_its_own_target(header_hex: str) -> bool:
"""Whether this header's hash satisfies the difficulty target *it claims for
itself* (the `bits` field). Rejects a header that was never actually mined —
e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28),
since satisfying a self-chosen target still requires real proof-of-work.
This does NOT — and, short of downloading and validating the full header
chain's difficulty-retarget history, cannot — catch a header honestly mined at
a real but implausibly low self-chosen difficulty: a server could still declare
an easy target and grind it out with modest hardware. That residual risk is why
the draw additionally requires the winning block's header to be corroborated by
the *other* configured servers before using it as the seed (see
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
rather than relying on this check alone.
"""
header_bytes = bytes.fromhex(header_hex)
if len(header_bytes) != _HEADER_LENGTH_BYTES:
return False
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
target = _target_from_bits(bits)
if target <= 0:
return False
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
# The hash as the integer comparable against `target`: this is the same digest
# header_hex_to_block_hash reverses into the conventional display hex, so
# reading it byte-reversed as a big-endian int is equivalent to reading the
# original digest bytes as little-endian — both give the same integer.
hash_int = int.from_bytes(digest, "little")
return hash_int <= target
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
"""v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer,
index = seed mod participant_count, winner = participants[index]. Anyone can
+54 -14
View File
@@ -1,16 +1,31 @@
import asyncio
from collections import defaultdict
# Defensive cap on concurrent SSE subscribers. Expected load is on the order of
# ~100 concurrent users; this is set well above that so it never engages under
# normal use — it exists purely so a runaway/DoS-y number of open connections
# degrades (new connections fall back to polling, see round_stream()) instead
# of growing the in-memory subscriber set without bound. Revisit this number if
# expected concurrency grows well past it.
# Defensive backstop on concurrent SSE subscribers overall, regardless of source
# — expected load is on the order of ~100 concurrent users, so this is set well
# above that. The real defense against a single abusive source is the per-IP cap
# below (B-38): a global-only cap was trivially exhausted by one client opening
# MAX_SUBSCRIBERS connections, degrading every other user to polling — the
# comment used to call it "defensive"; it was actually the vector.
MAX_SUBSCRIBERS = 500
# How many concurrent streams a single client IP may hold. Deliberately small —
# a real browser tab needs at most one, occasionally two briefly across a
# reload — since this bounds one source's share of the global capacity, not a
# legitimate per-user concurrency limit.
MAX_SUBSCRIBERS_PER_IP = 5
# Put on a to-be-evicted subscriber's queue (B-38) to wake its generator
# (app/api/routes/rounds.py:round_stream) promptly so it closes the connection
# instead of lingering, silently uncounted, until the client's own network
# timeout or the next keep-alive tick.
EVICTED = object()
class RoundEventCapacityError(Exception):
"""Raised by subscribe() when MAX_SUBSCRIBERS is already reached."""
"""Raised by subscribe() when MAX_SUBSCRIBERS — the global backstop — is
already reached. The per-IP cap never raises this; it evicts instead (see
subscribe())."""
class RoundEventBroadcaster:
@@ -26,22 +41,47 @@ class RoundEventBroadcaster:
would need a shared channel (e.g. Redis pub/sub) instead.
"""
def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS):
self._subscribers: set[asyncio.Queue] = set()
def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS, max_per_ip: int = MAX_SUBSCRIBERS_PER_IP):
self._ip_by_queue: dict[asyncio.Queue, str] = {}
self._queues_by_ip: dict[str, list[asyncio.Queue]] = defaultdict(list)
self.max_subscribers = max_subscribers
self.max_per_ip = max_per_ip
def subscribe(self) -> asyncio.Queue:
if len(self._subscribers) >= self.max_subscribers:
def subscribe(self, client_ip: str = "unknown") -> asyncio.Queue:
if len(self._ip_by_queue) >= self.max_subscribers:
raise RoundEventCapacityError(f"already at the {self.max_subscribers}-subscriber cap")
ip_queues = self._queues_by_ip[client_ip]
if len(ip_queues) >= self.max_per_ip:
# B-38: evict this IP's own oldest connection rather than refusing
# the new one — bounds one source's footprint without turning a
# legitimate reconnect storm (a flaky network retrying EventSource)
# into an outright block, and without letting one abusive IP crowd
# out unrelated clients the way the old global-only cap did.
oldest = ip_queues.pop(0)
self._ip_by_queue.pop(oldest, None)
if not oldest.full():
oldest.put_nowait(EVICTED)
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
self._subscribers.add(queue)
self._ip_by_queue[queue] = client_ip
ip_queues.append(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
client_ip = self._ip_by_queue.pop(queue, None)
if client_ip is None:
return
ip_queues = self._queues_by_ip.get(client_ip)
if ip_queues is None:
return
if queue in ip_queues:
ip_queues.remove(queue)
if not ip_queues:
self._queues_by_ip.pop(client_ip, None)
def publish(self) -> None:
for queue in self._subscribers:
for queue in self._ip_by_queue:
if queue.full():
continue # a not-yet-delivered notification already covers this one
queue.put_nowait(None)
+294 -46
View File
@@ -3,11 +3,12 @@ import logging
from datetime import datetime, timedelta, timezone
from embit import script
from embit.transaction import Transaction
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, Round, RoundParticipant, User
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.config import get_round_config
@@ -22,6 +23,20 @@ logger = logging.getLogger(__name__)
_TICK_INTERVAL_SECONDS = 5
# B-26: how long to wait after a payout failure before automatically retrying it.
# Long enough that a persistently-broken payout (misconfigured fee_address,
# insufficient pool UTXOs) doesn't re-attempt — and re-write a payout_failed audit
# entry — every _TICK_INTERVAL_SECONDS; short enough that a transient failure
# (a dropped Electrum connection, a momentarily-empty pool) self-heals quickly.
_PAYOUT_RETRY_INTERVAL_SECONDS = 60
# B-36: _wait_for_next_block has no timeout of its own — a round can legitimately
# wait several PLM blocks (120s each) for its draw entropy, and re-waits on a
# corroboration failure. These only make an already-long wait *observable*, they
# never cut it short.
_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60
_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
class RoundScheduler:
"""Background task implementing flowchart.mmd's DRAW subgraph: closes the
@@ -55,8 +70,17 @@ class RoundScheduler:
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
if status == "paying_out":
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
# any failure after that (no Electrum client, insufficient pool UTXOs, a
# rejected broadcast) or a process restart while paying_out left the round
# wedged here forever. Every tick now re-checks and retries, throttled by
# _retry_payout_if_due so a persistent failure doesn't retry on every tick.
await self._retry_payout_if_due(round_id)
return
if status not in ("open", "closing"):
return # already drawing/paying_out; progress happens elsewhere
return # "drawing" — progress happens inside the in-flight _close_and_draw call
if status == "open":
opened_at = opened_at.replace(tzinfo=timezone.utc)
@@ -75,10 +99,17 @@ class RoundScheduler:
broadcaster.publish()
async with self._session_factory() as session:
# "building" counts as in-flight too: it's a bet mid-broadcast (see
# bets/service.py's two-phase write). A bet that never confirms is
# eventually removed by app/tx/reconcile.py, which is what stops this
# wait from being unbounded.
pending_count = await session.scalar(
select(func.count())
.select_from(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "broadcast")
.where(
RoundParticipant.round_id == round_id,
RoundParticipant.status.in_(("building", "broadcast")),
)
)
if pending_count:
return # wait for in-flight bets to confirm before closing; stays "closing"
@@ -114,11 +145,13 @@ class RoundScheduler:
user_by_address[user.address] = user.id
round_.status = "drawing"
drawing_started_at = datetime.now(timezone.utc)
round_.drawing_started_at = drawing_started_at
await session.commit()
broadcaster.publish()
tip_at_close = self._listener.tip_height
block_height, block_hash = await self._wait_for_next_block(tip_at_close)
block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at)
winner_address = draw_winner(addresses, block_hash)
async with self._session_factory() as session:
@@ -148,75 +181,290 @@ class RoundScheduler:
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
await self._trigger_payout(round_id)
async def _wait_for_next_block(self, tip_at_close: int) -> tuple[int, str]:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
async def _wait_for_next_block(
self, round_id: int, tip_at_close: int, waiting_since: datetime
) -> tuple[int, str]:
"""Waits for a block after tip_at_close and, before handing it back as the
draw's entropy source, requires it to be corroborated by the other
configured Electrum servers (B-28) — our own active connection is exactly
the thing a hostile server or a MITM would control, so its header alone is
not enough to seed a payout. A candidate that fails corroboration is never
used: this keeps waiting for a further block and tries corroborating that
one instead, logging why every time so a stuck draw is visible in
/admin's audit log rather than a silent, unexplained wait.
This wait has no timeout — it can't, since the draw's entropy genuinely
depends on a future block. B-36: what it lacked was *visibility*, so a
connection that stopped advancing the tip left the round silently frozen
in "drawing" with nothing in the logs or /admin to explain why. Progress
is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a
draw_stalled audit entry is written (and re-written every threshold
interval for as long as the stall continues) so the wait shows up next
to the draw_header_corroboration_failed entries above.
"""
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
while True:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
now = datetime.now(timezone.utc)
if now >= next_progress_log_at:
logger.info(
"round %s: still waiting for a block past height %s (%.0fs since drawing started)",
round_id,
tip_at_close,
(now - waiting_since).total_seconds(),
)
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
if now >= next_stall_audit_at:
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_stalled",
{
"tip_at_close": tip_at_close,
"current_tip_height": self._listener.tip_height,
"elapsed_seconds": int((now - waiting_since).total_seconds()),
},
round_id=round_id,
)
await session.commit()
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
height = self._listener.tip_height
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
if await self._listener.corroborate_header(height, block_hash):
return height, block_hash
logger.error(
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
"waiting for a further block",
round_id,
height,
block_hash,
)
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_header_corroboration_failed",
{"height": height, "reported_hash": block_hash},
round_id=round_id,
)
await session.commit()
tip_at_close = height
async def _retry_payout_if_due(self, round_id: int) -> None:
"""B-26: whether a "paying_out" round is due for another payout attempt.
Throttled by the most recent payout_failed audit entry for this round
(written by _log_payout_failure on every early return in _trigger_payout,
including ones that used to fail silently) rather than by any new DB state,
since a failed attempt doesn't necessarily leave a PendingTransaction behind
(a build failure like a missing fee_address never gets that far). No entry
yet means this round hasn't failed before — either it's a fresh "paying_out"
(the very first call already happened from _close_and_draw and hasn't had a
chance to fail yet) or the process restarted before ever recording one —
either way it's due immediately.
"""
async with self._session_factory() as session:
last_failure_at = await session.scalar(
select(AuditLog.created_at)
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
.order_by(AuditLog.id.desc())
.limit(1)
)
if last_failure_at is not None:
last_failure_at = last_failure_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < last_failure_at + timedelta(seconds=_PAYOUT_RETRY_INTERVAL_SECONDS):
return # too soon — avoid hammering a persistently-broken payout
await self._trigger_payout(round_id)
async def _trigger_payout(self, round_id: int) -> None:
"""Four phases, so no DB session is held across a network call (B-18): read
what's needed, build the tx, persist the intent, then broadcast.
The persist happens *before* the broadcast (B-25) — the same two-phase shape
as place_bet/request_withdrawal (B-08): a crash between building the payout
and recording it used to leave money on-chain with zero trace in the DB (no
payout_txid, no PendingTransaction), so a manual retry would have paid the
winner a second time. Now the worst case is a "building" PendingTransaction
the reconciler (app/tx/reconcile.py) can resolve either way by asking the
chain whether the tx exists, exactly like it already does for bets and
withdrawals.
"""
client = self._listener.client
if client is None:
logger.error("round %s payout deferred: not connected", round_id)
await self._log_payout_failure(round_id, None, "electrum client not connected")
return
# --- Phase 1: read (session closed before any network I/O) ---------------
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
config = await get_round_config(session)
if not config.fee_address:
logger.error(
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
already_in_flight = await session.scalar(
select(PendingTransaction).where(
PendingTransaction.round_id == round_id,
PendingTransaction.kind == "payout",
PendingTransaction.status.in_(("building", "pending")),
)
)
if already_in_flight is not None:
# A payout for this round is already building or broadcast — this
# must not build a second one, or a retry (manual, or a future
# automatic one) would pay the winner twice. Confirmation/
# reconciliation already owns resolving that row.
logger.info(
"round %s payout already in flight (pending_transaction %s), skipping",
round_id,
already_in_flight.id,
)
return
reserved_outpoints = await _reserved_payout_outpoints(session)
config = await get_round_config(session)
fee_address = config.fee_address
fee_rate = config.fee_rate_sat_vb
pool_amount_sats = round_.pool_amount_sats
winner_user_id = round_.winner_user_id
winner = await session.get(User, winner_user_id)
winner_address = winner.address if winner is not None else None
await session.commit() # get_round_config may have created the row
winner = await session.get(User, round_.winner_user_id)
winner_share = round_.pool_amount_sats * 70 // 100
commission_share = round_.pool_amount_sats - winner_share # remainder from rounding goes to fees
if not fee_address:
logger.error(
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
)
await self._log_payout_failure(round_id, winner_user_id, "no fee_address configured")
return
if winner_address is None:
logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id)
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
return
winner_share = pool_amount_sats * 70 // 100
commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees
# --- Phase 2: build (network read only, no DB write yet) -----------------
try:
pool_key = derive_pool_key()
pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET)
pool_scripthash = address_to_scripthash(pool_address)
entries = await client.listunspent(pool_scripthash)
utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0]
entries = await client.listunspent(address_to_scripthash(pool_address))
utxos = [
Utxo(e["tx_hash"], e["tx_pos"], e["value"])
for e in entries
if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
]
try:
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script_obj,
utxos=utxos,
winner_address=winner.address,
winner_share_sats=winner_share,
fee_address=config.fee_address,
commission_sats=commission_share,
change_address=pool_address,
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
except InsufficientFundsError:
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
return
await client.broadcast(built.raw_hex)
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script_obj,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission_share,
change_address=pool_address,
fee_rate_sat_vb=fee_rate,
)
except InsufficientFundsError as exc:
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
# over more UTXOs than one transaction may spend, so /admin has to say which.
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
logger.exception("round %s payout failed: %s", round_id, reason)
await self._log_payout_failure(round_id, winner_user_id, reason)
return
except Exception:
# Anything else — a malformed fee_address (EmbitError) or similar. This
# used to escape all the way to run()'s catch-all, which logged it
# without recording anything, leaving no trace of *why* the round was
# stuck (B-05). _retry_payout_if_due (B-26) is what turns this recorded
# failure into an automatic retry instead of a dead end.
logger.exception("round %s payout build failed", round_id)
await self._log_payout_failure(round_id, winner_user_id, "payout build failed")
return
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
round_.winner_amount_sats = built.winner_sats
round_.fee_amount_sats = built.commission_sats
round_.payout_txid = built.txid
session.add(
PendingTransaction(
kind="payout",
round_id=round_id,
current_txid=built.txid,
fee_rate_sat_vb=config.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
)
pending = PendingTransaction(
kind="payout",
round_id=round_id,
current_txid=built.txid,
fee_rate_sat_vb=fee_rate,
raw_tx_hex=built.raw_hex,
# "building" until the broadcast succeeds, exactly like place_bet's
# two phases — see the reconciler, which gives this a short grace
# period before asking the chain whether it made it out after all.
status="building",
)
session.add(pending)
await session.commit()
pending_id = pending.id
# --- Phase 4: broadcast, then promote the pending row --------------------
try:
await client.broadcast(built.raw_hex)
except Exception:
# The row stays "building": the reconciler will ask the chain about it
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
# of the round being stuck with a payout_txid that never went anywhere.
logger.exception("round %s payout broadcast failed", round_id)
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
return
async with self._session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
pending.status = "pending"
await write_audit_log(
session,
"payout_sent",
{"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats},
user_id=round_.winner_user_id,
user_id=winner_user_id,
round_id=round_id,
)
await session.commit()
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None:
"""Leaves an operator-visible trace in the audit log for a round stuck in
"paying_out" — the logs alone don't show up in /admin. Called from every
early return in _trigger_payout (B-26), not just the generic exception
branch as before, so _retry_payout_if_due always has an entry to throttle
against and /admin always shows *why* a round is stuck rather than just
that it is."""
try:
async with self._session_factory() as session:
await write_audit_log(
session,
"payout_failed",
{"round_id": round_id, "reason": reason},
user_id=winner_user_id,
round_id=round_id,
)
await session.commit()
except Exception:
logger.exception("could not record the payout failure of round %s", round_id)
async def _reserved_payout_outpoints(session: AsyncSession) -> set[tuple[str, int]]:
"""Pool UTXOs already claimed by a payout that hasn't resolved yet — this
round's own in-flight payout (guarded against separately in _trigger_payout) or
a stale one from an earlier round the reconciler hasn't abandoned yet (B-25).
These must be excluded from selection, or a retry would double-spend the same
coins into two payouts before the reconciler gets a chance to release them."""
rows = (
await session.scalars(
select(PendingTransaction).where(
PendingTransaction.kind == "payout",
PendingTransaction.status.in_(("building", "pending")),
)
)
).all()
reserved: set[tuple[str, int]] = set()
for row in rows:
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
for vin in tx.vin:
reserved.add((vin.txid.hex(), vin.vout))
return reserved
+55 -11
View File
@@ -1,20 +1,43 @@
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Round
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
logger = logging.getLogger(__name__)
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
# Bounded: a conflict means someone else is opening a round right now, so a couple
# of retries is plenty. Unbounded retries could spin if the invariant were ever
# broken in a way we don't anticipate.
_OPEN_ROUND_ATTEMPTS = 3
async def get_active_round(session: AsyncSession) -> Round | None:
"""The round currently in progress (in any non-closed state), if any. Rounds
never overlap: a new round only opens once the previous one is fully closed
(payout confirmed, or no participants to pay out)."""
return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
(payout confirmed, or no participants to pay out).
The database enforces "at most one active round" (ix_rounds_single_active, see
app/db/models.py), so the ordering below is belt-and-braces; if it ever does
see two, that's a broken invariant and worth a loud log rather than silently
picking one."""
active = (
await session.scalars(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
).all()
if len(active) > 1:
logger.error(
"invariant violated: %s rounds are active at once (ids=%s) — using the newest",
len(active),
[r.id for r in active],
)
return active[0] if active else None
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
@@ -55,12 +78,33 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
return None
round_ = Round(status="open")
session.add(round_)
await session.flush()
# Published pre-commit (the caller commits right after) — acceptable: this
# only tells subscribers "go refetch", and by the time an SSE client's
# refetch request actually lands, this in-process commit (microseconds
# away) has essentially always already happened.
broadcaster.publish()
return round_
for attempt in range(_OPEN_ROUND_ATTEMPTS):
round_ = Round(status="open")
session.add(round_)
try:
await session.flush()
except IntegrityError:
# Another caller (the scheduler tick, or a concurrent place_bet) got
# there first — ix_rounds_single_active turns what used to be two live
# rounds into a clean failure here. Roll our insert back and use theirs.
# Safe to roll back: this runs before its callers have written anything
# else in this session.
await session.rollback()
existing = await get_active_round(session)
if existing is not None:
logger.info("lost the race to open a round; using round %s", existing.id)
return existing
# Nothing active *and* the insert conflicted: the winner's transaction
# hadn't committed yet when we looked. Try again rather than failing the
# caller — a bet shouldn't 500 because of a scheduler tick's timing.
logger.info("round-open conflict with nothing active yet (attempt %s), retrying", attempt + 1)
continue
# Published pre-commit (the caller commits right after) — acceptable: this
# only tells subscribers "go refetch", and by the time an SSE client's
# refetch request actually lands, this in-process commit (microseconds
# away) has essentially always already happened.
broadcaster.publish()
return round_
logger.error("could not open a round after %s attempts", _OPEN_ROUND_ATTEMPTS)
return await get_active_round(session)
+12 -4
View File
@@ -1,4 +1,12 @@
const SATS_PER_PLM = 100000000;
// Display formatter: a raw sats/SATS_PER_PLM division renders binary
// floating-point artefacts (0.7000000000000001) in the tables below (B-22).
// Input fields keep the raw value — they have to stay parseable.
function fmtPlm(sats) {
if (sats === null || sats === undefined) return '—';
return new Intl.NumberFormat('it-IT', { maximumFractionDigits: 8 }).format(sats / SATS_PER_PLM);
}
let adminToken = sessionStorage.getItem('plm_admin_token');
function toast(message, type) {
@@ -218,7 +226,7 @@ async function loadUsers() {
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td class="addr">${escapeHtml(u.address)}</td>
<td>${u.balance_sats / SATS_PER_PLM}</td>
<td>${fmtPlm(u.balance_sats)}</td>
<td>${fmtDate(u.created_at)}</td>
<td>
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
@@ -288,9 +296,9 @@ async function loadRounds() {
<td>${badge(r.status)}</td>
<td>${fmtDate(r.opened_at)}</td>
<td>${r.winner_username ? escapeHtml(r.winner_username) : '—'}</td>
<td>${r.pool_amount_sats != null ? r.pool_amount_sats / SATS_PER_PLM : '—'}</td>
<td>${r.winner_amount_sats != null ? r.winner_amount_sats / SATS_PER_PLM : '—'}</td>
<td>${r.fee_amount_sats != null ? r.fee_amount_sats / SATS_PER_PLM : '—'}</td>
<td>${fmtPlm(r.pool_amount_sats)}</td>
<td>${fmtPlm(r.winner_amount_sats)}</td>
<td>${fmtPlm(r.fee_amount_sats)}</td>
<td class="txid">${r.payout_txid ? escapeHtml(r.payout_txid) : '—'}</td>
</tr>
`).join('') || '<tr><td colspan="8" class="hint">Nessun round ancora.</td></tr>';
+60 -17
View File
@@ -1,5 +1,19 @@
const SATS_PER_PLM = 100000000;
// Every amount displayed goes through here. A bare sats/SATS_PER_PLM division
// leaks binary floating-point artefacts into the UI — a 0.7 PLM jackpot rendering
// as 0.7000000000000001 (B-22). Trailing zeros are trimmed so ordinary amounts
// stay readable, and grouping follows the selected language.
// Amounts sent *to* the server must NOT use this — they keep going through
// Math.round(x * SATS_PER_PLM), since this returns a formatted string.
function formatPlm(sats) {
if (sats === null || sats === undefined || Number.isNaN(sats)) return '—';
return new Intl.NumberFormat(currentDateLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits: 8,
}).format(sats / SATS_PER_PLM);
}
let token = localStorage.getItem('plm_token');
let username = localStorage.getItem('plm_username');
let address = localStorage.getItem('plm_address');
@@ -17,17 +31,35 @@ function toast(message, type) {
// <span data-i18n=...>, both of which a textContent round-trip would flatten away
// — losing the icon for good and, worse, stripping the data-i18n hook so the
// button would stop following later language changes.
//
// Only the outermost call owns the markup. refreshMe() is fired from the SSE
// handler, the poll chain, placeBet, withdraw and showDashboard, all sharing
// #refresh-btn: two overlapping calls used to make the second one snapshot the
// *loading* label and then restore it permanently, leaving the button stuck on
// "Aggiornamento…" (B-23). A nested call now just awaits the one already running.
const _loadingByButton = new WeakMap();
async function withLoading(button, label, fn) {
const inFlight = _loadingByButton.get(button);
if (inFlight) {
await inFlight.catch(() => {}); // its own caller reports the failure
return fn();
}
const original = button.innerHTML;
button.disabled = true;
button.textContent = label;
try {
await fn();
} finally {
button.disabled = false;
button.innerHTML = original;
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
}
const run = (async () => {
try {
await fn();
} finally {
button.disabled = false;
button.innerHTML = original;
applyStaticTranslations(button); // the snapshot may predate a language switch made while loading
_loadingByButton.delete(button);
}
})();
_loadingByButton.set(button, run);
return run;
}
const REQUEST_TIMEOUT_MS = 15000;
@@ -83,7 +115,7 @@ function errorParams(params) {
const out = { ...(params || {}) };
for (const [key, value] of Object.entries(params || {})) {
if (key.endsWith('_sats') && typeof value === 'number') {
out[key.slice(0, -5) + '_plm'] = value / SATS_PER_PLM;
out[key.slice(0, -5) + '_plm'] = formatPlm(value);
}
}
return out;
@@ -292,7 +324,7 @@ function renderPersistedResult(result) {
setRoundInfoVisible(false);
setResultBoxVisible(
true,
result.won ? t('result.win', { amount: result.amount_sats / SATS_PER_PLM }) : t('result.lose'),
result.won ? t('result.win', { amount: formatPlm(result.amount_sats) }) : t('result.lose'),
result.won ? 'win' : 'lose'
);
}
@@ -328,7 +360,7 @@ async function checkLastRoundResult() {
persistResult(data.round_id, data.won, data.amount_sats);
renderPersistedResult({ won: data.won, amount_sats: data.amount_sats });
if (data.won) {
const won = data.amount_sats / SATS_PER_PLM;
const won = formatPlm(data.amount_sats);
toast(t('toast.roundWon', { id: data.round_id, amount: won }), 'success');
refreshMe();
}
@@ -395,7 +427,7 @@ function renderBetButton() {
if (btn.disabled) return;
btn.textContent = betAmountSats === null
? t('bet.buttonNoAmount')
: t('bet.button', { amount: betAmountSats / SATS_PER_PLM });
: t('bet.button', { amount: formatPlm(betAmountSats) });
}
function showNormalState() {
@@ -422,7 +454,7 @@ async function refreshRound() {
document.getElementById('round-players').textContent = data.participant_count;
const jackpotEl = document.getElementById('round-jackpot');
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
jackpotEl.textContent = jackpotValue;
jackpotEl.textContent = formatPlm(data.jackpot_sats);
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
jackpotEl.classList.remove('jackpot-bump');
void jackpotEl.offsetWidth; // restart the animation
@@ -464,7 +496,7 @@ async function refreshRound() {
if (!alreadyKnown) {
persistResult(data.round_id, won, data.winner_amount_sats);
if (won) {
const wonAmount = (data.winner_amount_sats / SATS_PER_PLM);
const wonAmount = formatPlm(data.winner_amount_sats);
toast(t('toast.roundWon', { id: data.round_id, amount: wonAmount }), 'success');
refreshMe(); // the win toast is useless if the balance card still shows the pre-payout amount
}
@@ -555,6 +587,12 @@ async function register() {
toast(t('toast.passwordMismatch'), 'error');
return;
}
// Mirrors what the server now enforces (app/auth/routes.py's RegisterRequest),
// so the failure is immediate and translated instead of a generic 422 (B-12).
if (p.length < 8) {
toast(t('toast.passwordTooShort'), 'error');
return;
}
await withLoading(btn, t('loading.creating'), async () => {
try {
const data = await call('POST', '/auth/register', { username: u, password: p });
@@ -657,7 +695,7 @@ let myBalanceSats = 0; // confirmed, spendable balance — what withdrawals/bets
// has_pending is true so it's clear the figure isn't final yet.
function setBalanceDisplay(elementId, pendingBalanceSats, hasPending) {
const el = document.getElementById(elementId);
el.textContent = pendingBalanceSats / SATS_PER_PLM;
el.textContent = formatPlm(pendingBalanceSats);
el.classList.toggle('balance-pending', hasPending);
el.classList.toggle('balance-confirmed', !hasPending);
}
@@ -670,14 +708,14 @@ async function refreshMe() {
myUserId = data.id;
myBalanceSats = data.balance_sats;
setBalanceDisplay('dash-balance', data.pending_balance_sats, data.has_pending);
document.getElementById('navbar-balance').textContent = (data.pending_balance_sats / SATS_PER_PLM) + ' PLM';
document.getElementById('navbar-balance').textContent = formatPlm(data.pending_balance_sats) + ' PLM';
document.getElementById('navbar-balance').classList.toggle('balance-pending', data.has_pending);
document.getElementById('navbar-balance').classList.toggle('balance-confirmed', !data.has_pending);
document.getElementById('profile-username').textContent = data.username;
document.getElementById('profile-address').textContent = data.address;
setBalanceDisplay('profile-balance', data.pending_balance_sats, data.has_pending);
document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString(currentDateLocale());
document.getElementById('wd-full-amount-value').textContent = data.balance_sats / SATS_PER_PLM;
document.getElementById('wd-full-amount-value').textContent = formatPlm(data.balance_sats);
if (document.getElementById('wd-full-amount').checked) {
document.getElementById('wd-amount').value = data.balance_sats / SATS_PER_PLM;
}
@@ -711,10 +749,15 @@ async function changePassword() {
await withLoading(btn, t('loading.updating'), async () => {
try {
await call('POST', '/users/me/change-password', {
const data = await call('POST', '/users/me/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
// The server just invalidated every previously issued token (B-34) —
// including the one this very request was authenticated with — and
// handed back a fresh one so this tab doesn't get logged out too.
token = data.access_token;
localStorage.setItem('plm_token', token);
document.getElementById('settings-current-password').value = '';
document.getElementById('settings-new-password').value = '';
document.getElementById('settings-new-password-confirm').value = '';
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guida — PLM Lottery</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-shell">
<h1>Guida utente</h1>
<p>Questa pagina è un placeholder. La guida completa sarà pubblicata qui a breve.</p>
<p><a class="link" href="/">&larr; Torna alla home</a></p>
</div>
</body>
</html>
+56
View File
@@ -121,6 +121,7 @@ const TRANSLATIONS = {
'error.round_closing': 'The current round is closing, please try again shortly.',
'error.already_betting': 'You already have an active bet in the current round.',
'error.insufficient_balance': 'Insufficient balance.',
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.',
@@ -132,6 +133,13 @@ const TRANSLATIONS = {
'error.session_expired': 'Session expired, please log in again.',
'error.invalid_request': 'Invalid request, please check the entered data.',
'error.invalid_amount': 'Enter an amount greater than zero.',
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
'error.internal_error': 'Unexpected server error. Please try again shortly.',
'error.guide_unavailable': 'The guide is not available right now.',
'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.',
'loading.creating': 'Creating…',
'loading.loggingIn': 'Logging in…',
@@ -253,6 +261,7 @@ const TRANSLATIONS = {
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
'error.insufficient_balance': 'Saldo insufficiente.',
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.",
@@ -264,6 +273,13 @@ const TRANSLATIONS = {
'error.session_expired': 'Sessione scaduta, accedi di nuovo.',
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
'error.internal_error': 'Errore inatteso del server. Riprova tra poco.',
'error.guide_unavailable': 'La guida non è disponibile in questo momento.',
'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.',
'loading.creating': 'Creazione…',
'loading.loggingIn': 'Accesso…',
@@ -385,6 +401,7 @@ const TRANSLATIONS = {
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
'error.insufficient_balance': 'Saldo insuficiente.',
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.',
@@ -396,6 +413,13 @@ const TRANSLATIONS = {
'error.session_expired': 'Sesión caducada, vuelve a iniciar sesión.',
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
'error.invalid_amount': 'Introduce un importe mayor que cero.',
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.',
'error.guide_unavailable': 'La guía no está disponible en este momento.',
'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.',
'loading.creating': 'Creando…',
'loading.loggingIn': 'Entrando…',
@@ -517,6 +541,7 @@ const TRANSLATIONS = {
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
'error.insufficient_balance': 'Solde insuffisant.',
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.',
@@ -528,6 +553,13 @@ const TRANSLATIONS = {
'error.session_expired': 'Session expirée, veuillez vous reconnecter.',
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.",
'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.',
'loading.creating': 'Création…',
'loading.loggingIn': 'Connexion…',
@@ -649,6 +681,7 @@ const TRANSLATIONS = {
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
'error.insufficient_balance': 'Nicht genügend Guthaben.',
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.',
@@ -660,6 +693,13 @@ const TRANSLATIONS = {
'error.session_expired': 'Sitzung abgelaufen, bitte melde dich erneut an.',
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.',
'loading.creating': 'Wird erstellt…',
'loading.loggingIn': 'Anmeldung…',
@@ -781,6 +821,7 @@ const TRANSLATIONS = {
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
'error.insufficient_balance': 'Недостаточно средств.',
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.',
@@ -792,6 +833,13 @@ const TRANSLATIONS = {
'error.session_expired': 'Сессия истекла, войдите снова.',
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
'error.invalid_amount': 'Введите сумму больше нуля.',
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
'error.guide_unavailable': 'Руководство сейчас недоступно.',
'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.',
'loading.creating': 'Создание…',
'loading.loggingIn': 'Вход…',
@@ -913,6 +961,7 @@ const TRANSLATIONS = {
'error.round_closing': '当前回合正在结束,请稍后重试。',
'error.already_betting': '你在当前回合已有一笔有效下注。',
'error.insufficient_balance': '余额不足。',
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。',
@@ -924,6 +973,13 @@ const TRANSLATIONS = {
'error.session_expired': '会话已过期,请重新登录。',
'error.invalid_request': '请求无效,请检查填写的内容。',
'error.invalid_amount': '请输入大于零的金额。',
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
'error.amount_below_dust_limit': '金额过小,无法发送。',
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
'error.internal_error': '服务器发生意外错误,请稍后重试。',
'error.guide_unavailable': '指南当前不可用。',
'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。',
'loading.creating': '正在创建…',
'loading.loggingIn': '正在登录…',
+5 -5
View File
@@ -25,8 +25,8 @@
<a class="link icon-link" href="/guida" target="_blank" rel="noopener" data-i18n-title="nav.guideTitle" title="Guida" data-i18n-aria-label="nav.guideAria" aria-label="Apri la guida utente">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 2-3 4"/><path d="M12 17h.01"/></svg>
</a>
<a class="link icon-link" href="https://github.com/REPLACE_ME/plm-lottery/issues/new" target="_blank" rel="noopener" data-i18n-title="nav.bugReport" title="Segnala un bug" data-i18n-aria-label="nav.bugReport" aria-label="Segnala un bug su GitHub">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3M16 2v3M12 12v-2a2 2 0 1 1 2 2h-2Z"/><rect x="6" y="10" width="12" height="10" rx="4"/><path d="M6 15H3M21 15h-3M9 20v-3M15 20v-3"/></svg>
<a class="link icon-link" href="/report-bug" target="_blank" rel="noopener" data-i18n-title="nav.bugReport" title="Segnala un bug" data-i18n-aria-label="nav.bugReport" aria-label="Segnala un bug">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v5"/><path d="M12 16h.01"/></svg>
</a>
<button class="link icon-link" onclick="logout()" data-i18n-title="nav.logoutTitle" title="Esci" data-i18n-aria-label="nav.logoutAria" aria-label="Esci dall'account">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
@@ -131,11 +131,11 @@
<div class="tab-panel" id="panel-register">
<label for="reg-username" data-i18n="auth.username">Username</label>
<input id="reg-username" autocomplete="username">
<input id="reg-username" autocomplete="username" minlength="3" maxlength="32" pattern="[A-Za-z0-9_.\-]+" required>
<label for="reg-password" data-i18n="auth.password">Password</label>
<input id="reg-password" type="password" autocomplete="new-password">
<input id="reg-password" type="password" autocomplete="new-password" minlength="8" required>
<label for="reg-password-confirm" data-i18n="auth.passwordConfirm">Conferma password</label>
<input id="reg-password-confirm" type="password" autocomplete="new-password">
<input id="reg-password-confirm" type="password" autocomplete="new-password" minlength="8" required>
<button onclick="register()" id="register-btn" data-i18n="auth.registerBtn">Crea account</button>
</div>
</section>
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Segnala un bug — PLM Lottery</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-shell">
<h1>Segnala un bug</h1>
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
<p><a class="link" href="/">&larr; Torna alla home</a></p>
</div>
</body>
</html>
+155 -45
View File
@@ -9,17 +9,23 @@ from embit.finalizer import finalize_psbt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.models import PendingTransaction, User
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.wallet.hd import derive_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 30
_FEE_RATE_INCREMENT = 1 # minimum relay-policy-friendly bump per BIP125
_FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by per bump
# BIP125 rule 4: a replacement transaction must pay at least this much more, in
# total, per vbyte of its own size, than the transaction it replaces — Bitcoin
# Core's default incremental relay fee. bump_fee's delta must never fall below
# this regardless of what the target-rate arithmetic comes out to (B-32).
_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1
class RbfError(Exception):
@@ -27,22 +33,29 @@ class RbfError(Exception):
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool:
"""Pure decision: has this pending tx been unconfirmed for longer than the
configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the
I/O-heavy bump_fee() so it's trivially unit-testable."""
"""Pure decision: has this pending tx gone unconfirmed for longer than the
configured timeout (RoundConfig.rbf_timeout_seconds) *since it was last
broadcast*? Kept separate from the I/O-heavy bump_fee() so it's trivially
unit-testable.
Deliberately measured from last_broadcast_at, not broadcast_at: this decides
whether *another* bump is due, which should reset after every bump (a tx just
rebroadcast at a higher fee deserves the same grace period again) unlike
reconcile.py's abandon check, which must measure from the *first* broadcast so
repeated bumping can't indefinitely postpone ever giving up on a tx (B-27)."""
if pending.status != "pending":
return False
return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple:
async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple:
"""Returns (signing_key, own_script, own_address) for the single sender that
controls every input of this tx a user for bet/withdrawal, the pool for
payout. All our builders only ever spend one address's UTXOs per tx."""
if pending.kind == "payout":
if kind == "payout":
key = derive_pool_key()
else:
user = await session.get(User, pending.user_id)
user = await session.get(User, user_id)
key = derive_user_key(user.derivation_index)
own_script = script.p2wpkh(key.to_public())
own_address = own_script.address(network=PLM_MAINNET)
@@ -50,10 +63,19 @@ async def _signing_context(session: AsyncSession, pending: PendingTransaction) -
async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int:
"""The exact integer satoshi value of the output this input spends.
Parsed directly from the raw transaction via embit rather than asking the
server for its own float, whole-coin-denominated "value" field (verbose=True)
and converting with `* 100_000_000` embit's TransactionOutput.value is
already an integer number of satoshis straight from the tx's binary
encoding, so this never touches floating point in a codebase that is
otherwise strictly integer-satoshi (B-40).
"""
txid_hex = vin.txid.hex()
tx = await client.get_transaction(txid_hex, verbose=True)
value_coins = tx["vout"][vin.vout]["value"]
return round(value_coins * 100_000_000)
raw_hex = await client.get_transaction(txid_hex, verbose=False)
prevout_tx = Transaction.parse(bytes.fromhex(raw_hex))
return prevout_tx.vout[vin.vout].value
def _find_change_output(tx: Transaction, change_address: str) -> int | None:
@@ -63,33 +85,71 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
return None
async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str:
"""Rebuild `pending`'s transaction with a higher fee (same inputs, same
recipient outputs, the extra fee taken from the change output) and
rebroadcast. Returns the new txid.
async def bump_fee(
session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
) -> str | None:
"""Rebuild pending_transaction `pending_id`'s transaction with a higher fee
(same inputs, same recipient outputs, the extra fee taken from the change
output) and rebroadcast. Returns the new txid, or None if there was nothing
to do (the row is gone or already left "pending" a normal race with
confirmation, not an error).
Three phases, so no DB session is held across the network calls this needs
(one get_transaction per input, then a broadcast) the same shape used
elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout;
B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's
needed and close the session, do the chain work, then reopen to persist.
Only handles the common case: exactly one change output paying back to the
tx's own sender address, large enough to absorb the increase. If there's no
such output (e.g. an exact-amount bet with no change), this raises RbfError
bumping such a tx would require selecting additional inputs, which isn't
implemented for the MVP; it needs manual operator intervention.
implemented for the MVP; it needs manual operator intervention. Also raises
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
(B-32) the reconciler abandons it if it never confirms (B-27), instead of
this retrying an ever-higher fee forever.
"""
old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
signing_key, own_script, own_address = await _signing_context(session, pending)
# --- Phase 1: read what's needed, close the session before any network call ---
async with session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
if pending is None or pending.status != "pending":
logger.info("pending_transaction %s no longer pending; skipping bump", pending_id)
return None
if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB:
raise RbfError(
f"pending_transaction {pending_id}: already at the maximum fee rate "
f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further"
)
kind = pending.kind
current_fee_rate = pending.fee_rate_sat_vb
raw_tx_hex = pending.raw_tx_hex
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id)
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
total_in = sum(input_amounts)
old_fee = total_in - sum(o.value for o in old_tx.vout)
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
new_fee_rate = pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT
new_fee = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) * new_fee_rate
fee_delta = new_fee - old_fee
if fee_delta <= 0:
fee_delta = _FEE_RATE_INCREMENT # already above the new target vsize*rate; bump by a token amount
target_fee_rate = min(current_fee_rate + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB)
target_fee = vsize * target_fee_rate
# BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor
# `fee_delta` must never go below, no matter what `target_fee - old_fee` comes
# out to. That naive difference used to go to zero or negative whenever
# old_fee already exceeded target_fee (e.g. a dust change amount folded into
# the original fee — wallet/psbt_builder.py's DUST_LIMIT_SATS handling), and
# the previous fallback — a flat 1-satoshi total bump — was nowhere near this
# relay-mandated minimum, so the node rejected it every time. Because bump_fee
# raised before touching `pending`, the next tick retried with identical
# parameters every 30 seconds, forever (B-32).
min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB
fee_delta = max(target_fee - old_fee, min_valid_delta)
change_index = _find_change_output(old_tx, own_address)
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
raise RbfError(f"pending_transaction {pending.id}: no change output large enough to absorb a fee bump")
raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump")
new_vout = list(old_tx.vout)
bumped_change = new_vout[change_index].value - fee_delta
@@ -113,17 +173,71 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi
new_txid = final_tx.txid().hex()
await client.broadcast(raw_hex)
pending.current_txid = new_txid
pending.raw_tx_hex = raw_hex
pending.fee_rate_sat_vb = new_fee_rate
pending.attempt_count += 1
pending.broadcast_at = datetime.now(timezone.utc)
await session.commit()
# --- Phase 3: persist the outcome ----------------------------------------------
async with session_factory() as session:
pending = await session.get(PendingTransaction, pending_id)
old_txid = pending.current_txid
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
pending.current_txid = new_txid
pending.raw_tx_hex = raw_hex
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
# floor above raised fee_delta past the naive target, the tx now pays more
# than target_fee_rate implied. Recording the true rate keeps the next bump's
# arithmetic honest instead of drifting from what's really being paid.
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
pending.attempt_count += 1
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
# from it — overwriting it here used to reset that clock on every bump, so a
# repeatedly-bumped-but-never-mined tx was never abandoned.
pending.last_broadcast_at = datetime.now(timezone.utc)
await _retarget_txid_references(session, pending, old_txid, new_txid)
await session.commit()
logger.info("bumped %s pending_transaction %s: %s -> %s", pending.kind, pending.id, pending.current_txid, new_txid)
logger.info("bumped %s pending_transaction %s: %s -> %s", kind, pending_id, old_txid, new_txid)
return new_txid
async def _retarget_txid_references(
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
) -> None:
"""A bump changes the txid, and everything that recorded the old one has to
follow otherwise the bumped tx confirms and nothing recognizes it (B-02).
The worst case was the bet path: _on_bet_confirmed used to look the participant
up by bet_txid, so after a bump it found nothing, the participant stayed
"broadcast" forever, and the scheduler waited on it forever the round could
never close and the lottery stopped. The handlers now key off immutable ids
(round_id/user_id, withdrawal_id) as well, so this update is about keeping the
stored txids *true* for the admin UI, for the audit trail, and for
reconcile.py, which matches UtxoEvent.spent_txid against current_txid.
"""
if pending.kind == "bet":
participant = await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == pending.round_id,
RoundParticipant.user_id == pending.user_id,
)
)
if participant is not None:
participant.bet_txid = new_txid
elif pending.kind == "withdrawal" and pending.withdrawal_id is not None:
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
if withdrawal is not None:
withdrawal.txid = new_txid
elif pending.kind == "payout" and pending.round_id is not None:
round_ = await session.get(Round, pending.round_id)
if round_ is not None and round_.payout_txid == old_txid:
round_.payout_txid = new_txid
# The UTXOs this tx spends are still the same UTXOs — only the id of the tx
# spending them changed. Keeping this in step is what lets reconcile.py tell
# "reserved by this pending tx" from "spent by something else".
spent = (await session.scalars(select(UtxoEvent).where(UtxoEvent.spent_txid == old_txid))).all()
for utxo in spent:
utxo.spent_txid = new_txid
class RbfBumper:
def __init__(self, session_factory: async_sessionmaker, get_client):
self._session_factory = session_factory
@@ -148,16 +262,12 @@ class RbfBumper:
candidates = (
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
).all()
due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)]
for pending in due:
async with self._session_factory() as session:
row = await session.get(PendingTransaction, pending.id)
if row is None or row.status != "pending":
continue
try:
await bump_fee(session, client, row)
except RbfError:
logger.exception("could not bump pending_transaction %s", row.id)
except Exception:
logger.exception("unexpected error bumping pending_transaction %s", row.id)
for pending_id in due_ids:
try:
await bump_fee(self._session_factory, client, pending_id)
except RbfError:
logger.exception("could not bump pending_transaction %s", pending_id)
except Exception:
logger.exception("unexpected error bumping pending_transaction %s", pending_id)
+49 -6
View File
@@ -7,7 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.models import PendingTransaction
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.tx.pending_address import own_address_for
logger = logging.getLogger(__name__)
@@ -26,16 +28,57 @@ def register_handler(kind: str, handler: ConfirmationHandler) -> None:
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
async with session_factory() as session:
pending = (
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
# Plain columns, not entities: nothing then outlives the session, so this
# can't break if expire_on_commit is ever turned on (B-21).
candidates = (
await session.execute(
select(
PendingTransaction.id,
PendingTransaction.current_txid,
PendingTransaction.kind,
PendingTransaction.user_id,
).where(PendingTransaction.status == "pending")
)
).all()
pending_ids = [p.id for p in pending]
# Resolved once per candidate while the session is still open, and cached
# by scripthash below — every "payout" row shares the same pool address,
# so this also avoids asking the server the same history twice per tick.
scripthash_by_id: dict[int, str] = {}
for pending_id, _txid, kind, user_id in candidates:
try:
address = await own_address_for(session, kind, user_id)
scripthash_by_id[pending_id] = address_to_scripthash(address)
except Exception:
logger.exception("could not derive the address for pending_transaction %s", pending_id)
confirmed = 0
for pending_id, txid, kind in [(p.id, p.current_txid, p.kind) for p in pending]:
tx = await client.get_transaction(txid, verbose=True)
if not tx or tx.get("confirmations", 0) < 1:
history_cache: dict[str, list[dict]] = {}
for pending_id, txid, kind, _user_id in candidates:
scripthash = scripthash_by_id.get(pending_id)
if scripthash is None:
continue # address derivation failed above; already logged
try:
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
except Exception:
# One unresolvable scripthash must not stop the others: a tx the server
# no longer knows about (dropped from the mempool, replaced) used to
# abort the whole pass via a verbose blockchain.transaction.get call
# that some servers reject outright (B-41), so nothing confirmed again
# until an operator intervened (B-03). Abandoning such a row is
# app/tx/reconcile.py's job, not ours.
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
continue
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
# height > 0 means confirmed at that height; 0 or absent means still in
# the mempool (or the server doesn't know this txid at all yet) — either
# way, not confirmed, so keep waiting.
if entry is None or entry.get("height", 0) <= 0:
continue
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User
from app.wallet.hd import derive_pool_address, derive_user_address
async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str:
"""The address that owns every input of a PendingTransaction of this kind —
a user's own address for a bet/withdrawal, the pool address for a payout.
All our builders only ever spend one address's UTXOs per tx (see
tx/broadcast.py:_signing_context, which derives the same address alongside
the signing key it also needs).
Shared by tx/confirmation.py and tx/reconcile.py (B-41): both now check
blockchain.scripthash.get_history for this address instead of asking
blockchain.transaction.get for a verbose reply, so the two can't derive
different addresses for the same row.
"""
if kind == "payout":
return derive_pool_address()
user = await session.get(User, user_id)
return derive_user_address(user.derivation_index)
+260
View File
@@ -0,0 +1,260 @@
"""Resolves in-flight transactions against the chain.
Everything else in this codebase assumes a broadcast either confirms or gets
fee-bumped until it does. Neither is guaranteed: an RBF bump raises RbfError
whenever there's no change output big enough to absorb it (see tx/broadcast.py),
a node can drop a low-fee tx from its mempool, and the process can die between
building a transaction and broadcasting it. Without this module those cases were
permanent: `spent_txid` was set at build time and never cleared, so the coins
stayed spendable on-chain while the database considered them gone the user's
balance simply lost them, with no path back short of editing the DB by hand
(B-04, and the "building" half of B-08).
What it does, per PendingTransaction that isn't already terminal:
* status "building" we crashed (or were killed) between writing the row and
broadcasting. Ask the chain: if the tx is there after all, promote everything
to its live state; if it isn't, release the UTXOs and undo the intent.
* status "pending" broadcast, still unconfirmed. Left alone until it has been
unconfirmed for `_ABANDON_AFTER_SECONDS`, since absence from one server's
mempool is not proof of death; only then is it abandoned like the above.
Deliberately conservative: it never touches a tx the chain knows about, and the
grace period is long (multiples of the RBF timeout) so a slow-but-alive tx is
bumped by RbfBumper rather than abandoned here.
"""
import asyncio
import logging
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from embit.transaction import Transaction
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.tx.pending_address import own_address_for
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 120
# A "building" row means we never got confirmation that the broadcast happened, so
# it only needs long enough to rule out a request still in flight.
_BUILDING_GRACE_SECONDS = 120
# A "pending" row was accepted by a node once. Give it a wide margin — the RBF
# bumper gets several attempts inside this window — before concluding it's gone.
_ABANDON_AFTER_SECONDS = 6 * 60 * 60
class PendingTransactionReconciler:
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
self._session_factory = session_factory
self._get_client = get_client
async def run(self) -> None:
# Runs once promptly at startup: a crash mid-broadcast is exactly the case
# that leaves a "building" row, and the restart is when we can clear it.
while True:
client = self._get_client()
if client is not None:
try:
await reconcile_once(self._session_factory, client)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("pending-transaction reconciliation failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
"""Returns how many rows were resolved (promoted or abandoned).
Existence is decided by checking whether a row's own address's history
(blockchain.scripthash.get_history) includes its txid at all mempool or
mined rather than asking blockchain.transaction.get for a verbose reply
(B-41): several Electrum server implementations and versions reject the
verbose flag outright, and the previous substring-matching on the error
text (looking for "missing", "not found", ...) was fragile as the basis for
a decision that releases funds. A transport failure fetching history still
raises and leaves the row alone until next time get_history not
returning our txid is the only thing that means "gone". History is cached
per scripthash within one pass, since every "payout" row shares the same
pool address.
"""
now = datetime.now(timezone.utc)
async with session_factory() as session:
rows = (
await session.scalars(
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending")))
)
).all()
candidates = []
for row in rows:
if not _is_due(row, now):
continue
try:
address = await own_address_for(session, row.kind, row.user_id)
scripthash = address_to_scripthash(address)
except Exception:
logger.exception("could not derive the address for pending_transaction %s", row.id)
continue
candidates.append((row.id, row.status, row.current_txid, scripthash))
resolved = 0
history_cache: dict[str, list[dict]] = {}
for row_id, status, txid, scripthash in candidates:
try:
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
except Exception:
# Transport/server problem — say nothing about this tx and try again on
# the next pass rather than abandoning a tx that may be perfectly alive.
logger.warning("could not check pending_transaction %s (txid %s) against the chain", row_id, txid)
continue
async with session_factory() as session:
row = await session.get(PendingTransaction, row_id)
if row is None or row.status != status:
continue # something else moved it while we were asking
if exists:
if row.status == "building":
await _promote(session, row)
resolved += 1
else:
await _abandon(session, row, "not found on chain")
resolved += 1
await session.commit()
broadcaster.publish()
return resolved
def _is_due(row: PendingTransaction, now: datetime) -> bool:
# Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an
# RBF bump used to overwrite this same field, which reset this grace period on
# every bump and meant a repeatedly-bumped-but-never-mined tx was never
# abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches
# last_broadcast_at, so this keeps measuring from when the tx first appeared,
# no matter how many times it's since been bumped.
grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS
return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace)
async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
"""The tx did make it onto the chain before we died — finish what phase 2 of
place_bet/request_withdrawal would have done."""
row.status = "pending"
if row.kind == "bet":
participant = await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == row.round_id, RoundParticipant.user_id == row.user_id
)
)
if participant is not None and participant.status == "building":
participant.status = "broadcast"
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
if withdrawal is not None and withdrawal.status == "building":
withdrawal.status = "broadcast"
await write_audit_log(
session,
"pending_tx_recovered",
{"pending_transaction_id": row.id, "kind": row.kind, "txid": row.current_txid},
user_id=row.user_id,
round_id=row.round_id,
)
logger.info("recovered %s pending_transaction %s: tx %s is on-chain", row.kind, row.id, row.current_txid)
async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) -> None:
"""The tx is gone for good. Release whatever it reserved so the funds come back,
and roll the domain row back to something truthful."""
row.status = "failed"
row.failure_reason = reason[:128]
released = await _release_inputs(session, row)
if row.kind == "bet":
participant = await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == row.round_id, RoundParticipant.user_id == row.user_id
)
)
if participant is not None and participant.status in ("building", "broadcast"):
# The bet never happened, so the user is not in this round. Removing the
# row also unblocks the scheduler, which waits for every non-confirmed
# participant before closing the round.
await session.delete(participant)
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
if withdrawal is not None and withdrawal.status in ("building", "broadcast"):
withdrawal.status = "failed"
withdrawal.txid = None
elif row.kind == "payout":
# Payout funds come from the pool address, which isn't tracked in
# utxo_events, so there's nothing to release. Clearing payout_txid leaves the
# round in "paying_out" with no tx attached, which is the state an operator
# (or a future payout-retry routine — still an open gap) can act on.
round_ = await session.get(Round, row.round_id) if row.round_id else None
if round_ is not None and round_.status == "paying_out":
round_.payout_txid = None
logger.error(
"round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid
)
if row.user_id is not None:
await recompute_balance(session, row.user_id)
await write_audit_log(
session,
"pending_tx_abandoned",
{
"pending_transaction_id": row.id,
"kind": row.kind,
"txid": row.current_txid,
"reason": reason,
"utxos_released": released,
},
user_id=row.user_id,
round_id=row.round_id,
)
logger.warning(
"abandoned %s pending_transaction %s (txid %s): %s — released %s UTXO(s)",
row.kind,
row.id,
row.current_txid,
reason,
released,
)
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
counts them again. The inputs come from the stored raw tx, which is kept current
across RBF bumps, so this works for a bumped tx too."""
try:
tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex))
except Exception:
logger.exception("could not parse raw tx of pending_transaction %s; inputs not released", row.id)
return 0
released = 0
for vin in tx.vin:
utxo = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
)
# Only release what this tx actually reserved: if another tx has since spent
# the same UTXO, its claim is the live one and must not be cleared.
if utxo is not None and utxo.spent_txid == row.current_txid:
utxo.spent_txid = None
released += 1
return released
+4 -1
View File
@@ -46,7 +46,10 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
select(PendingTransaction).where(
PendingTransaction.user_id == user.id,
PendingTransaction.kind.in_(("bet", "withdrawal")),
PendingTransaction.status == "pending",
# "building" as well as "pending": a building row's UTXOs are already
# marked spent (see place_bet's two phases), so leaving it out would
# make the displayed balance dip for the duration of the broadcast.
PendingTransaction.status.in_(("building", "pending")),
)
)
).all()
+68 -5
View File
@@ -17,15 +17,46 @@ _P2WPKH_OUTPUT_VBYTES = 31
# input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
RBF_SEQUENCE = 0xFFFFFFFD
# Below this, an output costs more to spend than it's worth and relay policy rejects
# the whole transaction as "dust" — so a small change amount must be left to the fee
# instead of being paid back to ourselves. 294 sat is the standard P2WPKH threshold
# (the output's own 31 vbytes plus the 67-vbyte input needed to spend it, at the
# 3000 sat/kvB dust relay fee). Creating such an output used to make the bet or
# withdrawal fail at broadcast with an opaque error (B-06).
DUST_LIMIT_SATS = 294
# Sanity ceiling on any transaction's fee rate — shared by RoundConfig.fee_rate_sat_vb's
# admin-facing bound (app/api/routes/admin.py, so the two can't drift apart, the same
# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF
# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) —
# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever,
# eating further and further into the sender's change with no limit.
MAX_FEE_RATE_SAT_VB = 10_000
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
# stops being standard and gets refused at broadcast. Failing the build with a
# translatable error is the honest outcome; consolidating the address is the way out.
MAX_TX_INPUTS = 50
class InsufficientFundsError(Exception):
"""`code` is the machine-readable identifier the API layer forwards to the
client so it can translate the failure (see app/api/errors.py); the message
itself stays English."""
itself stays English, and `params` carries the values it interpolates so the
translation can place them wherever its own grammar needs them."""
def __init__(self, message: str, code: str = "insufficient_balance") -> None:
def __init__(
self,
message: str,
code: str = "insufficient_balance",
**params: int | str,
) -> None:
super().__init__(message)
self.code = code
self.params = params
@dataclass
@@ -52,11 +83,22 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering
target_sats the amount deducted from the sender's balance. The fee is paid
out of target_sats (see build_signed_transaction), not added on top of it."""
out of target_sats (see build_signed_transaction), not added on top of it.
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
UTXOs don't cover the target, the balance is there but too fragmented to spend
in one transaction, which is a different failure from having no funds at all
and gets its own code."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
if len(selected) == MAX_TX_INPUTS:
raise InsufficientFundsError(
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
code="too_many_inputs",
max_inputs=MAX_TX_INPUTS,
)
selected.append(utxo)
total += utxo.amount_sats
if total >= target_sats:
@@ -81,6 +123,10 @@ def build_signed_transaction(
receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender.
A change amount below DUST_LIMIT_SATS is dropped and left to the fee paying it
back to ourselves would produce an unrelayable transaction. The fee estimate
already assumes two outputs, so dropping one never underpays.
"""
selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
@@ -90,6 +136,13 @@ def build_signed_transaction(
"amount too small to cover the network fee", code="amount_below_network_fee"
)
change = total_in - amount_sats
if change < DUST_LIMIT_SATS:
fee += change # dust change is unspendable and unrelayable — miners get it
change = 0
if recipient_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"amount too small to be sent (dust)", code="amount_below_dust_limit"
)
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
# embit reverses it internally when serializing to wire format.
@@ -147,16 +200,26 @@ def build_payout_transaction(
) -> PayoutTransaction:
"""Build, sign and finalize the round payout: pool -> winner + fee address,
with change back to the pool itself. Per spec, only the winner's share
absorbs the tx fee the commission (fee_address) output is untouched."""
absorbs the tx fee the commission (fee_address) output is untouched.
As in build_signed_transaction, dust-sized pool change is left to the fee
rather than creating an unrelayable output (B-06)."""
target = winner_share_sats + commission_sats
selected, total_in = select_utxos(utxos, target)
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
winner_amount = winner_share_sats - fee
if winner_amount <= 0:
if winner_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"winner share too small to cover the network fee", code="winner_share_below_network_fee"
)
if commission_sats < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"commission share too small to be paid out (dust)", code="commission_below_dust_limit"
)
change = total_in - target
if change < DUST_LIMIT_SATS:
fee += change
change = 0
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [
+3 -1
View File
@@ -10,7 +10,9 @@ async def _on_withdrawal_confirmed(session: AsyncSession, pending: PendingTransa
if pending.withdrawal_id is None:
return
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
if withdrawal is not None and withdrawal.status == "broadcast":
# "building" is reachable if we confirmed before the reconciler promoted the row
# (a crash between broadcast and commit — see app/tx/reconcile.py).
if withdrawal is not None and withdrawal.status in ("building", "broadcast"):
withdrawal.status = "confirmed"
withdrawal.confirmed_at = datetime.now(timezone.utc)
+92 -17
View File
@@ -9,9 +9,14 @@ from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.wallet.address import is_valid_plm_address
from app.wallet.balance import recompute_balance
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction
from app.wallet.psbt_builder import (
BuiltTransaction,
InsufficientFundsError,
Utxo,
build_signed_transaction,
)
class WithdrawalError(ApiError):
@@ -27,6 +32,17 @@ async def request_withdrawal(
if not is_valid_plm_address(external_address):
raise WithdrawalError("invalid_address", "not a valid PLM bech32 address")
# Withdrawing to your own deposit address is a no-op that costs a network fee,
# and it breaks two things that assume the recipient and the change are
# distinguishable by address: the RBF bump would shrink the recipient output
# instead of the change (tx/broadcast.py:_find_change_output), and
# compute_pending_balance would count the amount twice (B-17).
if external_address == user.address:
raise WithdrawalError(
"withdrawal_to_own_address",
"that is your own deposit address — withdraw to an external wallet instead",
)
config = await get_round_config(session)
if amount_sats < config.bet_amount_sats:
raise WithdrawalError(
@@ -40,7 +56,22 @@ async def request_withdrawal(
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
)
).all()
if sum(u.amount_sats for u in unspent) < amount_sats:
confirmed_sats = sum(u.amount_sats for u in unspent)
if confirmed_sats < amount_sats:
# B-37: cached_balance_sats (== confirmed_sats here) can understate the real
# balance by a whole unconfirmed change output right after a bet/withdrawal —
# the UI shows pending_balance_sats instead (compute_pending_balance), which
# can cover an amount this check would otherwise reject as flatly
# "insufficient". Distinguish "you don't have the money" from "your money
# hasn't confirmed yet" so the error doesn't contradict what the user is
# looking at on screen.
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user)
if has_pending and pending_inclusive_sats >= amount_sats:
raise WithdrawalError(
"balance_pending_confirmation",
"the requested amount is covered by your pending balance, which has not confirmed yet",
pending_sats=pending_inclusive_sats - confirmed_sats,
)
raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats)
user_key = derive_user_key(user.derivation_index)
@@ -58,10 +89,10 @@ async def request_withdrawal(
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc)) from exc
await client.broadcast(built.raw_hex)
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
# Persist the intent before broadcasting, and only promote the rows once the
# network has accepted the tx — same two-phase shape as place_bet (B-08).
spent_by_key = {(u.txid, u.vout): u for u in unspent}
for spent in built.spent_utxos:
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
@@ -73,21 +104,32 @@ async def request_withdrawal(
amount_requested_sats=amount_sats,
amount_sent_sats=built.recipient_sats,
txid=built.txid,
status="broadcast",
status="building",
)
session.add(withdrawal)
await session.flush()
session.add(
PendingTransaction(
kind="withdrawal",
withdrawal_id=withdrawal.id,
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=config.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
)
pending = PendingTransaction(
kind="withdrawal",
withdrawal_id=withdrawal.id,
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=config.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="building",
)
session.add(pending)
await session.commit()
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
raise WithdrawalError(
"broadcast_failed", f"the network refused the transaction: {exc}"
) from exc
withdrawal.status = "broadcast"
pending.status = "pending"
await write_audit_log(
session,
"withdrawal_sent",
@@ -99,3 +141,36 @@ async def request_withdrawal(
await session.refresh(withdrawal)
broadcaster.publish() # balance just went "pending" — nudge the dashboard to refetch
return withdrawal
async def _release_failed_withdrawal(
session: AsyncSession,
withdrawal: Withdrawal,
pending: PendingTransaction,
built: BuiltTransaction,
user_id: int,
reason: str,
) -> None:
"""Nothing reached the chain, so free the reserved UTXOs and restore the balance.
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
withdrawal is an instruction the user gave, and they should be able to see that it
didn't go through."""
for spent in built.spent_utxos:
row = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
)
if row is not None:
row.spent_txid = None
withdrawal.status = "failed"
withdrawal.txid = None
pending.status = "failed"
pending.failure_reason = reason[:128]
await recompute_balance(session, user_id)
await write_audit_log(
session,
"withdrawal_broadcast_failed",
{"txid": built.txid, "reason": reason[:200]},
user_id=user_id,
)
await session.commit()
broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49)
+25 -2
View File
@@ -108,11 +108,22 @@ piazzate, payout inviati, round chiusi, configurazione modificata, accessi
alle chiavi private, ecc. È il primo posto da controllare per ricostruire
cosa è successo dopo un problema.
Eventi a cui vale la pena prestare attenzione:
| Evento | Significato |
|---|---|
| `config_updated` | Un parametro è stato modificato; il payload contiene valore precedente e nuovo per ogni campo cambiato. |
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | La rete ha rifiutato la transazione. Non è stato speso nulla: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. |
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. |
## Alternative all'interfaccia grafica
Le stesse operazioni si possono fare da terminale o da Swagger UI
(`https://<host>/docs`, sezione `admin`), sempre passando `ADMIN_TOKEN`
nell'header `X-Admin-Token`:
(`https://<host>/docs`, sezione `admin` — disponibile solo se `ENABLE_API_DOCS=true`
è impostato in `.env`, disattivata di default perché espone l'intera API),
sempre passando `ADMIN_TOKEN` nell'header `X-Admin-Token`:
```bash
# leggere la configurazione
@@ -125,6 +136,18 @@ curl -X PUT https://<host>/admin/config \
-d '{"fee_address": "plm1q...", "bet_amount_sats": 1000000000, "round_duration_seconds": 600}'
```
I valori vengono validati: `fee_address` deve essere un indirizzo bech32 PLM
valido (`plm1...`) e i parametri numerici hanno limiti di buon senso
(`fee_rate_sat_vb` almeno 1, durata round almeno 30s, ecc.). Un valore fuori
range viene rifiutato con un errore 422 e la configurazione resta invariata.
Il controllo su `fee_address` è deliberatamente severo: un indirizzo di
un'altra catena (per esempio `bc1...`) sarebbe formalmente valido come witness
program, e il 30% di commissione di ogni round finirebbe su uno script di cui
nessuno ha la chiave.
```bash
```
## Utenti, chiave privata e reset password
La card "Utenti" elenca id, username, indirizzo e saldo di ogni utente
+7 -17
View File
@@ -3,24 +3,14 @@
Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto,
master key generata, migrazioni applicate).
## Locale / venv (sviluppo rapido)
Il server gira sempre via Docker, in sviluppo e in produzione allo stesso
modo — non esiste un modo supportato per lanciare `uvicorn` direttamente.
Il venv locale (`.venv/`) serve solo per i test, per scrivere le migrazioni
Alembic e per gli script una tantum di generazione chiavi (vedi
[setup.md](setup.md) e la sezione "Commands" di
[CLAUDE.md](../CLAUDE.md#commands)).
```bash
source .venv/bin/activate
uvicorn app.main:app --reload --port 8123
```
- App su `http://127.0.0.1:8123/`
- Pannello admin su `http://127.0.0.1:8123/admin`
- Log applicativi in `logs/app.log` (rotante, 10MB × 5 backup)
- Nessun TLS, nessun reverse proxy — solo per test locali sulla tua macchina.
Per fermarlo: `Ctrl+C`, oppure se lanciato in background con `nohup`:
```bash
pkill -f "uvicorn app.main:app"
```
## Docker + Caddy (consigliato, anche per i test con dominio/TLS)
## Docker + Caddy (unico workflow supportato)
```bash
mkdir -p data/db data/keys data/logs # una tantum, se non già presenti
+28 -1
View File
@@ -11,6 +11,13 @@ poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
- **Consigliato in produzione: più di un server.** Tutto passa da questa singola
connessione (accredito depositi, invio transazioni, conferme, altezza della
catena su cui si basa l'estrazione), quindi un solo server è il principale
punto di rottura della piattaforma. Elencane altri in
`ELECTRUM_FALLBACK_SERVERS` (vedi sotto): l'app li prova a rotazione, così un
server irraggiungibile costa un solo tentativo di riconnessione invece di un
disservizio.
## 2. Creare il file `.env`
@@ -29,7 +36,27 @@ cp .env.example .env
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`,
`MASTER_KEY_PATH`) hanno default sensati in `.env.example`. Nota: `.env`
`MASTER_KEY_PATH`) hanno default sensati in `.env.example`.
`ENABLE_API_DOCS` (default `false`) controlla Swagger/ReDoc/l'OpenAPI JSON grezzo
su `/docs`, `/redoc` e `/openapi.json`: espongono l'intera superficie dell'API,
endpoint admin inclusi, quindi restano disattivati a meno di non impostarlo
esplicitamente a `true` — utile in locale, da evitare in produzione.
`ELECTRUM_FALLBACK_SERVERS` elenca i server di riserva, separati da virgola, nel
formato `host:porta` (TLS, il caso normale) oppure `host:porta:notls`. Esempio:
```
ELECTRUM_FALLBACK_SERVERS=nodo2.example.net:50002,nodo3.example.net:50001:notls
```
Vengono provati a rotazione dopo il primario. Attenzione: un valore scritto male
**blocca l'avvio** dell'app — è voluto, meglio accorgersene subito che durante il
disservizio in cui il fallback serve davvero.
`JWT_SECRET` e `XPRV_ENCRYPTION_KEY` vengono verificati all'avvio: se sono vuoti
(o `JWT_SECRET` è più corto di 32 caratteri) il container si rifiuta di partire con
un errore esplicito, invece di avviarsi e rompersi al primo login. Nota: `.env`
contiene solo segreti e configurazione di infrastruttura — i parametri di
business (bet amount, durata round, fee, ecc.) si configurano dal pannello
admin dopo l'avvio, non qui — vedi [guida-admin.md](guida-admin.md).
@@ -0,0 +1,41 @@
"""add last_broadcast_at to pending_transactions
Fixes B-27: bump_fee used to overwrite broadcast_at on every RBF bump, but
tx/reconcile.py's abandon-after-N-hours grace period is measured from that same
column so a transaction bumped repeatedly but never mined reset that clock on
every bump and was never abandoned. broadcast_at now stays the *first* broadcast
(what the reconciler measures from); last_broadcast_at is the new column bump_fee
updates and should_bump reads to decide whether another bump is due.
Backfilled from the existing broadcast_at (the best available approximation for
rows written before this column existed for a row never bumped it's exact)
before the NOT NULL constraint is applied, so this is safe against any existing
data.
Revision ID: 861e76aaf34c
Revises: 8a1c4e7b2d90
Create Date: 2026-07-27
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '861e76aaf34c'
down_revision: Union[str, Sequence[str], None] = '8a1c4e7b2d90'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('pending_transactions', sa.Column('last_broadcast_at', sa.DateTime(), nullable=True))
op.execute('UPDATE pending_transactions SET last_broadcast_at = broadcast_at')
with op.batch_alter_table('pending_transactions') as batch_op:
batch_op.alter_column('last_broadcast_at', nullable=False)
def downgrade() -> None:
op.drop_column('pending_transactions', 'last_broadcast_at')
@@ -0,0 +1,38 @@
"""widen raw_tx_hex and payload_json to Text
Fixes B-47: both columns held arbitrary-length data (a raw signed transaction
hex, and a JSON audit payload) in an unbounded `String`, which SQLAlchemy
compiles to `VARCHAR` with no length. That's accepted by SQLite and
PostgreSQL but rejected by other backends (e.g. MySQL requires a length on
VARCHAR) `Text` is the portable type for both.
Revision ID: 87a0c640355c
Revises: 9ef6a51509f7
Create Date: 2026-07-27
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '87a0c640355c'
down_revision: Union[str, Sequence[str], None] = '9ef6a51509f7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('audit_log') as batch_op:
batch_op.alter_column('payload_json', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False)
with op.batch_alter_table('pending_transactions') as batch_op:
batch_op.alter_column('raw_tx_hex', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False)
def downgrade() -> None:
with op.batch_alter_table('pending_transactions') as batch_op:
batch_op.alter_column('raw_tx_hex', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False)
with op.batch_alter_table('audit_log') as batch_op:
batch_op.alter_column('payload_json', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False)
@@ -0,0 +1,57 @@
"""Add pending tx failure_reason and the single-active-round index
Supports two fixes from BUGS.md:
* B-04 the reconciler (app/tx/reconcile.py) records *why* it abandoned a
transaction, so an operator can tell a dropped tx from a rejected one.
* B-09 "at most one active round" becomes a database guarantee instead of a
read-then-insert that two concurrent callers could both pass. A unique index
over the constant expression (1), restricted to the active statuses: any number
of closed rounds, only ever one live one.
The index creation is not blind: if an instance already has more than one active
round (the very bug this prevents), creating it would fail with an opaque
IntegrityError mid-migration. It closes the stale duplicates first, keeping the
newest which is exactly what get_active_round was already doing silently.
Revision ID: 8a1c4e7b2d90
Revises: 6cb50b29f64c
Create Date: 2026-07-26
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "8a1c4e7b2d90"
down_revision: Union[str, Sequence[str], None] = "6cb50b29f64c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'"
def upgrade() -> None:
op.add_column(
"pending_transactions", sa.Column("failure_reason", sa.String(length=128), nullable=True)
)
connection = op.get_bind()
active_ids = [
row[0]
for row in connection.execute(
sa.text(f"SELECT id FROM rounds WHERE status IN ({_ACTIVE}) ORDER BY id DESC")
)
]
for stale_id in active_ids[1:]:
connection.execute(
sa.text("UPDATE rounds SET status = 'closed' WHERE id = :id"), {"id": stale_id}
)
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
op.drop_column("pending_transactions", "failure_reason")
@@ -0,0 +1,40 @@
"""add token_version to users
Revision ID: 943dbd74d983
Revises: 861e76aaf34c
Create Date: 2026-07-27
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '943dbd74d983'
down_revision: Union[str, Sequence[str], None] = '861e76aaf34c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# server_default backfills every existing user to 0 (their current sessions
# stay valid, since 0 also matches what already-issued tokens carry
# implicitly — see the "sub"-only tokens issued before this migration);
# dropped right after so new rows go through the ORM default instead of a
# stale constant.
op.add_column(
'users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0')
)
with op.batch_alter_table('users') as batch_op:
batch_op.alter_column('token_version', server_default=None)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'token_version')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add drawing_started_at to rounds
Revision ID: 9ef6a51509f7
Revises: 943dbd74d983
Create Date: 2026-07-27 12:31:09.907682
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9ef6a51509f7'
down_revision: Union[str, Sequence[str], None] = '943dbd74d983'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('rounds', sa.Column('drawing_started_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rounds', 'drawing_started_at')
# ### end Alembic commands ###
+165 -3
View File
@@ -4,6 +4,11 @@ from httpx import ASGITransport, AsyncClient
from app.config import settings
# A real PLM bech32 address: PUT /admin/config now validates fee_address, since a
# foreign-chain address there would send every round's commission to a script
# nobody can spend (B-05).
_VALID_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
@pytest.fixture
async def client(monkeypatch, tmp_path):
@@ -62,6 +67,15 @@ async def test_admin_rejects_wrong_token(client):
assert resp.status_code == 403
async def test_admin_rejects_non_ascii_token_with_403_not_500(client):
"""B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which
used to bubble up as a 500 instead of the expected 403. httpx encodes str
header values as ASCII client-side, so the raw UTF-8 bytes are passed
directly to reproduce what a real non-ASCII header on the wire looks like."""
resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")})
assert resp.status_code == 403
async def test_admin_reads_and_updates_config(client):
headers = {"X-Admin-Token": "test-admin-token"}
@@ -70,15 +84,15 @@ async def test_admin_reads_and_updates_config(client):
assert resp.json()["fee_address"] == ""
resp = await client.put(
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
"/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS, "bet_amount_sats": 500_000_000}
)
assert resp.status_code == 200
body = resp.json()
assert body["fee_address"] == "plm1qfeeaddress"
assert body["fee_address"] == _VALID_FEE_ADDRESS
assert body["bet_amount_sats"] == 500_000_000
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["fee_address"] == "plm1qfeeaddress"
assert resp.json()["fee_address"] == _VALID_FEE_ADDRESS
async def test_admin_can_pause_and_resume_the_lottery(client):
@@ -200,6 +214,10 @@ async def test_admin_resets_user_password(client):
assert refreshed.password_hash != old_hash
assert verify_password(new_password, refreshed.password_hash)
assert not verify_password("original-password", refreshed.password_hash)
# B-34: the reset must bump token_version so a session opened before
# the reset (e.g. an attacker who had the old password) is evicted
# immediately rather than staying valid until the JWT naturally expires.
assert refreshed.token_version == 1
async def test_admin_reset_password_requires_token(client):
@@ -211,3 +229,147 @@ async def test_admin_reset_password_404_for_unknown_user(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.post("/admin/users/999/reset-password", headers=headers)
assert resp.status_code == 404
async def test_config_update_is_audit_logged(client):
"""B-10: /pause and /resume were logged but a config change wasn't, so the most
sensitive setting in the system fee_address, where 30% of every pool goes
could be changed without leaving any trace."""
headers = {"X-Admin-Token": "test-admin-token"}
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
resp = await client.get("/admin/audit-log", headers=headers)
entries = [e for e in resp.json() if e["event_type"] == "config_updated"]
assert len(entries) == 1
assert entries[0]["payload"]["fee_address"] == {"from": "", "to": _VALID_FEE_ADDRESS}
async def test_config_update_without_changes_logs_nothing(client):
headers = {"X-Admin-Token": "test-admin-token"}
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
resp = await client.get("/admin/audit-log", headers=headers)
assert len([e for e in resp.json() if e["event_type"] == "config_updated"]) == 1
@pytest.mark.parametrize(
"payload",
[
{"fee_address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"}, # valid bech32, wrong chain
{"fee_address": "plm1qbogus"}, # right HRP, broken checksum
{"fee_address": "garbage"},
{"fee_rate_sat_vb": 0}, # fee-less txs are never relayed: everything would stall
{"round_duration_seconds": 0}, # a round that expires the instant it opens
{"bet_amount_sats": -1},
{"rbf_timeout_seconds": 1},
],
)
async def test_config_rejects_unusable_values(client, payload):
"""B-05: every one of these was accepted before. The bc1 case is the worst — it
parses as a valid witness program, so each round's commission would be broadcast
to a script nobody holds the key for."""
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.put("/admin/config", headers=headers, json=payload)
assert resp.status_code == 422
# and nothing was written
current = (await client.get("/admin/config", headers=headers)).json()
for field, value in payload.items():
assert current[field] != value
async def test_pause_cannot_be_toggled_through_the_config_endpoint(client):
"""B-10: `paused` used to be settable here, bypassing the audit-logged
pause/resume endpoints."""
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.put("/admin/config", headers=headers, json={"paused": True})
assert resp.status_code in (200, 422) # ignored or refused, but never applied
assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False
@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"])
@pytest.mark.parametrize("bad_limit", [0, -1, 501])
async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit):
"""B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an
unvalidated limit could dump the entire table in one response."""
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit})
assert resp.status_code == 422
async def test_admin_list_rounds_respects_limit(client):
from app.db import base as db_base
from app.db.models import Round
async with db_base.AsyncSessionLocal() as session:
session.add_all([Round(status="closed") for _ in range(3)])
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2})
assert resp.status_code == 200
assert len(resp.json()) == 2
async def test_admin_audit_log_respects_limit(client):
from app.db import base as db_base
from app.audit.log import write_audit_log
async with db_base.AsyncSessionLocal() as session:
for _ in range(3):
await write_audit_log(session, "test_event", {})
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2})
assert resp.status_code == 200
assert len(resp.json()) == 2
async def _make_pending_transaction(session, *, kind="bet", status="pending"):
from app.db.models import PendingTransaction
import secrets as _secrets
tx = PendingTransaction(
kind=kind,
current_txid=_secrets.token_hex(32),
fee_rate_sat_vb=1,
raw_tx_hex="00",
status=status,
)
session.add(tx)
return tx
async def test_admin_pending_transactions_respects_limit(client):
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
for _ in range(3):
await _make_pending_transaction(session)
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2})
assert resp.status_code == 200
assert len(resp.json()) == 2
async def test_admin_pending_transactions_status_filter(client):
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
await _make_pending_transaction(session, status="pending")
await _make_pending_transaction(session, status="confirmed")
await _make_pending_transaction(session, status="failed")
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get(
"/admin/pending-transactions", headers=headers, params={"status": "confirmed"}
)
assert resp.status_code == 200
entries = resp.json()
assert len(entries) == 1
assert entries[0]["status"] == "confirmed"
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
from app.api.timeutil import isoformat_utc
def test_naive_datetime_is_stamped_utc():
# SQLite/aiosqlite round-trips DateTime columns as naive even though every
# value written is UTC (app.db.models.utcnow) — this is the exact shape
# returned by the ORM after a read (B-35).
naive = datetime(2026, 7, 27, 6, 56, 47, 489110)
result = isoformat_utc(naive)
assert result == "2026-07-27T06:56:47.489110+00:00"
def test_aware_datetime_is_left_unchanged():
aware = datetime(2026, 7, 27, 6, 56, 47, tzinfo=timezone.utc)
assert isoformat_utc(aware) == aware.isoformat()
def test_none_passes_through():
assert isoformat_utc(None) is None
+126
View File
@@ -0,0 +1,126 @@
import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return resp.json()["access_token"]
async def test_login_locks_out_after_repeated_failures(client):
await _register(client)
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
# Even the *correct* password is refused while locked out — the throttle
# protects against a lucky guess landing inside the backoff window too.
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 429
async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client):
await _register(client, username="bob")
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "invalid_credentials"
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 429
async def test_login_failures_against_one_account_do_not_lock_out_another(client):
await _register(client, username="alice")
await _register(client, username="carol", password="carols-password")
for _ in range(6):
await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
# Different username, but same IP (the test client always looks the same) —
# only the per-username bucket should be exhausted, not the whole IP, since
# the per-username threshold (5) is hit well before the shared IP bucket's.
resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"})
assert resp.status_code == 200
async def test_successful_login_resets_the_username_bucket(client):
await _register(client)
for _ in range(4):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
async def test_registration_is_rate_limited_per_ip(client):
for i in range(5):
resp = await client.post(
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
)
assert resp.status_code == 201
resp = await client.post(
"/auth/register", json={"username": "user5", "password": "a-strong-password"}
)
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
+127
View File
@@ -8,8 +8,10 @@ from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_user_address
from app.wallet.psbt_builder import MAX_TX_INPUTS
class FakeElectrumClient:
@@ -91,6 +93,35 @@ async def test_place_bet_rejects_insufficient_balance(session_factory):
await place_bet(session, client, user)
async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48
# 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs
# only add up to 7.5 PLM — so the build must fail with its own code, not with
# the "you have no funds" one, and must carry the cap for the translation.
user_id = await _make_funded_user(session_factory, 20, 15_000_000)
async with session_factory() as session:
for i in range(99):
session.add(
UtxoEvent(
user_id=user_id,
txid=f"{i:064x}",
vout=0,
amount_sats=15_000_000,
confirmed_height=100,
)
)
await session.commit()
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, client, user)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
assert not client.broadcasted
async def test_place_bet_rejects_second_bet_same_round(session_factory):
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient()
@@ -133,3 +164,99 @@ async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_
assert len(participants) == 0
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "open" # scheduler hasn't ticked — status is unchanged, only the check is deadline-aware
class RejectingElectrumClient:
"""A node that refuses the transaction — fee too low, dust output, mempool
conflict, or simply an unreachable server."""
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
async def test_failed_broadcast_leaves_nothing_behind(session_factory):
"""B-07/B-08: the broadcast used to happen before anything was written, so a
rejection left the UTXOs marked spent with no rows to explain it, and the caller
got an opaque HTTP 500. Now it's a translatable error and a full rollback."""
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="refused"):
await place_bet(session, RejectingElectrumClient(), user)
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid is None # released, so the user can bet again
assert (await session.scalars(select(RoundParticipant))).all() == []
assert (await session.scalars(select(PendingTransaction))).all() == []
user = await session.get(User, user_id)
assert user.cached_balance_sats == 3_000_000_000
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "bet_broadcast_failed" in events
assert "bet_placed" not in events
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The rollback moves as much state as the successful path does, so it must ping
the dashboards the same way otherwise the phantom bet stays on screen until the
next poll."""
user_id = await _make_funded_user(session_factory, 21, 3_000_000_000)
async with session_factory() as session:
# Open the round up front: place_bet would otherwise open it itself, and that
# publish() would satisfy the assertion below whether or not the rollback ever
# published one of its own.
await open_new_round_if_needed(session)
await session.commit()
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="refused"):
await place_bet(session, RejectingElectrumClient(), user)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory):
user_id = await _make_funded_user(session_factory, 5, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
try:
await place_bet(session, RejectingElectrumClient(), user)
assert False, "expected BetError"
except BetError as exc:
assert exc.code == "broadcast_failed"
async def test_bet_is_persisted_before_it_is_broadcast(session_factory):
"""The ordering guarantee behind B-08: by the time the network call happens, the
rows already exist, so a crash there is recoverable rather than silent."""
user_id = await _make_funded_user(session_factory, 6, 3_000_000_000)
seen: dict[str, object] = {}
class ObservingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
# Read committed state from an independent session, mid-broadcast.
async with session_factory() as probe:
seen["pending"] = [
(p.kind, p.status) for p in (await probe.scalars(select(PendingTransaction))).all()
]
seen["participants"] = [
(p.status) for p in (await probe.scalars(select(RoundParticipant))).all()
]
return "network-txid"
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, ObservingClient(), user)
assert seen["pending"] == [("bet", "building")]
assert seen["participants"] == ["building"]
+395 -10
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
import pytest
from embit import script
from embit.bip32 import HDKey
from embit.transaction import Transaction
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
@@ -11,7 +11,7 @@ from app.db.base import Base
from app.db.models import PendingTransaction, User
from app.tx.broadcast import RbfError, bump_fee, should_bump
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import Utxo, build_signed_transaction
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, Utxo, build_signed_transaction, estimate_vsize
def _key(seed_byte: int) -> HDKey:
@@ -22,7 +22,7 @@ def _key(seed_byte: int) -> HDKey:
def test_should_bump_false_before_timeout():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc),
broadcast_at=datetime.now(timezone.utc), last_broadcast_at=datetime.now(timezone.utc),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
@@ -31,6 +31,7 @@ def test_should_bump_true_after_timeout():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True
@@ -39,17 +40,41 @@ def test_should_bump_false_when_not_pending():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch):
"""B-27 regression: a tx first broadcast long ago, but bumped recently, must not
be due for another bump yet should_bump has to look at last_broadcast_at, not
the original broadcast_at, or every tick would try to re-bump it."""
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=10_000),
last_broadcast_at=datetime.now(timezone.utc),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
class FakeClient:
"""B-40: _prevout_amount now asks for the raw (non-verbose) transaction and
reads its output value as an integer via embit, rather than a verbose reply's
float "value" field so this fake must hand back a real, parseable raw tx
whose vout[0] carries the requested amount (every test here spends vout 0 of
its fixture UTXO)."""
def __init__(self, prevout_values: dict[str, int]):
self._prevout_values = prevout_values
self.broadcasted: list[str] = []
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}}
async def get_transaction(self, txid: str, verbose: bool = False) -> str:
assert verbose is False
fake_prevout_tx = Transaction(
vin=[TransactionInput(b"\x00" * 32, 0)],
vout=[TransactionOutput(self._prevout_values[txid], script.Script(b"\x00\x14" + b"\x00" * 20))],
)
return fake_prevout_tx.serialize().hex()
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
@@ -116,9 +141,7 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
new_txid = await bump_fee(session, client, row)
new_txid = await bump_fee(session_factory, client, pending_id)
assert client.broadcasted
assert new_txid != built.txid
@@ -136,6 +159,61 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
assert row.attempt_count == 2
async def test_bump_fee_leaves_broadcast_at_untouched(session_factory):
"""B-27 regression: bump_fee must only ever update last_broadcast_at. Before
this, it overwrote broadcast_at on every bump the same field
tx/reconcile.py's abandon-after-N-hours grace period measures from — so a
repeatedly-bumped-but-never-mined tx reset that clock forever and was never
abandoned."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "33" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
original_broadcast_at = datetime.now(timezone.utc) - timedelta(days=1)
async with session_factory() as session:
user = User(username="carol", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=original_broadcast_at,
last_broadcast_at=original_broadcast_at,
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({utxo_txid: utxo_amount})
before_bump = datetime.now(timezone.utc)
await bump_fee(session_factory, client, pending_id)
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.broadcast_at.replace(tzinfo=timezone.utc) == original_broadcast_at
assert row.last_broadcast_at.replace(tzinfo=timezone.utc) >= before_bump
async def test_bump_fee_raises_when_no_change_output(session_factory):
from app.wallet.hd import derive_user_address, derive_user_key
@@ -175,7 +253,314 @@ async def test_bump_fee_raises_when_no_change_output(session_factory):
client = FakeClient({utxo_txid: utxo_amount})
with pytest.raises(RbfError):
await bump_fee(session_factory, client, pending_id)
async def test_bump_fee_retargets_every_stored_txid(session_factory):
"""B-02/B-20: a bump changes the txid, and everything that recorded the old one
has to follow the participant's bet_txid (whose staleness used to wedge the
round forever), the UTXO's spent_txid (which the reconciler matches on), and
replaced_by_txid, which was never written at all."""
from app.db.models import Round, RoundParticipant, UtxoEvent
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "22" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
session.add(Round(id=1, status="open"))
await session.flush()
session.add(
UtxoEvent(
user_id=user.id, txid=utxo_txid, vout=0, amount_sats=utxo_amount,
confirmed_height=5, spent_txid=built.txid,
)
)
session.add(
RoundParticipant(
round_id=1, user_id=user.id, bet_amount_sats=built.recipient_sats,
bet_txid=built.txid, status="broadcast",
)
)
pending = PendingTransaction(
kind="bet", round_id=1, user_id=user.id, current_txid=built.txid, fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex, status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
new_txid = await bump_fee(session_factory, FakeClient({utxo_txid: utxo_amount}), pending_id)
async with session_factory() as session:
from sqlalchemy import select
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid == new_txid
assert row.replaced_by_txid == built.txid # points backwards at what it replaced
participant = (await session.scalars(select(RoundParticipant))).one()
assert participant.bet_txid == new_txid
utxo = (await session.scalars(select(UtxoEvent))).one()
assert utxo.spent_txid == new_txid
# --- B-32: the bump delta must always meet BIP125's relay-mandated minimum, and
# escalation must stop at a ceiling instead of retrying forever. ------------------
async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target(session_factory):
"""old_fee (as bump_fee computes it from the actual prevout amounts) can end
up higher than vsize * target_fee_rate e.g. because dust change was folded
into the original fee (wallet/psbt_builder.py's DUST_LIMIT_SATS handling).
The naive `target_fee - old_fee` goes negative in that case; the previous
fallback was a flat 1-satoshi total bump, nowhere near BIP125 rule 4's
required minimum, so the node rejected it every time and since bump_fee
raised before touching `pending` the next tick retried identically every
30 seconds, forever. Simulated here by reporting a prevout inflated beyond
what was actually spent, which has the same effect on old_fee as dust
absorption would."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(96).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "55" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="dave", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
# Reports a prevout inflated well beyond what was actually spent — has the
# same effect on old_fee as dust absorption would have: old_fee ends up far
# above vsize * target_fee_rate (target_fee_rate = 2 here).
inflated_excess = 50_000
client = FakeClient({utxo_txid: utxo_amount + inflated_excess})
new_txid = await bump_fee(session_factory, client, pending_id)
assert client.broadcasted
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout))
min_valid_delta = vsize * 1 # BIP125 rule 4's floor at a 1 sat/vB incremental relay fee
assert min_valid_delta > 1 # meaningfully more than the old flat "1 satoshi" fallback
assert old_change - new_change == min_valid_delta
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
with pytest.raises(RbfError):
await bump_fee(session, client, row)
old_fee_as_bump_fee_computed_it = built.fee_sats + inflated_excess
expected_rate = (old_fee_as_bump_fee_computed_it + min_valid_delta) // vsize
assert row.fee_rate_sat_vb == expected_rate
assert row.fee_rate_sat_vb > 2 # the actual rate, not the naive (and too-low) target
async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory):
"""Without a ceiling, a stuck transaction's fee rate climbed by 1 sat/vB every
30 seconds forever, eating further and further into the user's change."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(95).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "66" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="erin", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=MAX_FEE_RATE_SAT_VB,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({utxo_txid: utxo_amount})
with pytest.raises(RbfError):
await bump_fee(session_factory, client, pending_id)
assert not client.broadcasted
# --- B-40: bump_fee must not hold a DB session open across its network calls,
# and a row that's no longer pending by the time it runs is a quiet no-op. -------
async def test_bump_fee_holds_no_session_open_during_network_calls(session_factory):
"""The get_transaction-per-input reads and the broadcast must happen with no
DB session held open the same shape used elsewhere for this reason (B-18,
electrum/listener.py's refresh_user for B-31) — otherwise a session sits
idle in the pool for the whole duration of what can be several slow network
round-trips."""
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(94).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "77" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="frank", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
open_count = {"n": 0}
class _TrackedSession:
def __init__(self, inner):
self._inner = inner
async def __aenter__(self):
result = await self._inner.__aenter__()
open_count["n"] += 1
return result
async def __aexit__(self, *exc):
open_count["n"] -= 1
return await self._inner.__aexit__(*exc)
def tracking_session_factory():
return _TrackedSession(session_factory())
class TrackingClient(FakeClient):
async def get_transaction(self, txid, verbose=False):
assert open_count["n"] == 0, "a session was held open during a network call"
return await super().get_transaction(txid, verbose)
async def broadcast(self, raw_tx_hex):
assert open_count["n"] == 0, "a session was held open during the broadcast"
return await super().broadcast(raw_tx_hex)
client = TrackingClient({utxo_txid: utxo_amount})
await bump_fee(tracking_session_factory, client, pending_id)
assert client.broadcasted
assert open_count["n"] == 0 # nothing left open afterwards either
async def test_bump_fee_is_a_noop_when_no_longer_pending(session_factory):
"""A row can legitimately confirm (or otherwise leave "pending") between
being read as due and RbfBumper actually attempting the bump a normal
race, not an error. Must return quietly rather than raising or touching
the network."""
async with session_factory() as session:
user = User(username="grace", password_hash="x", derivation_index=0, address="plm1qxxx")
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid="already-confirmed-txid",
fee_rate_sat_vb=1,
raw_tx_hex="00",
status="confirmed",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({})
result = await bump_fee(session_factory, client, pending_id)
assert result is None
assert not client.broadcasted
async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory):
client = FakeClient({})
result = await bump_fee(session_factory, client, 999_999)
assert result is None
assert not client.broadcasted
@@ -0,0 +1,35 @@
"""B-43: the Caddyfile must keep sending baseline security headers. Caddy adds
none of these on its own, and the JWT lives in localStorage, so a regression
here silently reopens an XSS/clickjacking exposure with no test ever failing
in the Python suite (the Caddyfile isn't imported/exercised by anything else)."""
from pathlib import Path
CADDYFILE = (Path(__file__).parent.parent.parent / "Caddyfile").read_text()
def test_header_block_present():
assert "header {" in CADDYFILE
def test_hsts_is_set():
assert "Strict-Transport-Security" in CADDYFILE
assert "max-age=" in CADDYFILE
def test_nosniff_is_set():
assert 'X-Content-Type-Options "nosniff"' in CADDYFILE
def test_frame_ancestors_are_blocked():
assert 'X-Frame-Options "DENY"' in CADDYFILE
assert "frame-ancestors 'none'" in CADDYFILE
def test_referrer_policy_is_set():
assert "Referrer-Policy" in CADDYFILE
def test_csp_default_src_is_self():
assert "Content-Security-Policy" in CADDYFILE
assert "default-src 'self'" in CADDYFILE
+43
View File
@@ -0,0 +1,43 @@
"""app.api.client_ip is shared by the login/registration throttles (B-33) and
the SSE per-IP subscriber cap (B-38) both depend on it correctly preferring
X-Forwarded-For (Caddy reverse-proxies every request, see Caddyfile) over
request.client.host, which would otherwise be the proxy's own address."""
from starlette.requests import Request
from app.api.client_ip import client_ip
def _request(*, forwarded: str | None = None, client_host: str | None = "127.0.0.1") -> Request:
headers = [(b"x-forwarded-for", forwarded.encode())] if forwarded else []
scope = {
"type": "http",
"headers": headers,
"client": (client_host, 12345) if client_host else None,
}
return Request(scope)
def test_client_ip_prefers_x_forwarded_for():
request = _request(forwarded="5.6.7.8", client_host="10.0.0.1")
assert client_ip(request) == "5.6.7.8"
def test_client_ip_takes_the_first_hop_of_a_forwarded_chain():
request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1")
assert client_ip(request) == "5.6.7.8"
def test_client_ip_strips_whitespace():
request = _request(forwarded=" 5.6.7.8 , 10.0.0.1")
assert client_ip(request) == "5.6.7.8"
def test_client_ip_falls_back_to_request_client_without_the_header():
request = _request(forwarded=None, client_host="10.0.0.1")
assert client_ip(request) == "10.0.0.1"
def test_client_ip_falls_back_to_unknown_with_neither():
request = _request(forwarded=None, client_host=None)
assert client_ip(request) == "unknown"
+51
View File
@@ -0,0 +1,51 @@
"""B-15: secrets that would only break at first use must stop the app at startup."""
import pytest
from app.config import MIN_JWT_SECRET_LENGTH, ConfigError, Settings, validate_runtime_secrets
def _settings(**overrides) -> Settings:
base = {
"jwt_secret": "x" * MIN_JWT_SECRET_LENGTH,
"xprv_encryption_key": "a-fernet-key",
}
base.update(overrides)
# _env_file=None so a developer's real .env can't make this test pass or fail.
return Settings(_env_file=None, **base)
def test_valid_secrets_pass():
validate_runtime_secrets(_settings())
def test_empty_jwt_secret_is_refused():
"""An empty JWT_SECRET makes PyJWT raise InvalidKeyError on every single login —
a 500 with no hint about the real cause, on a container that started up healthy."""
with pytest.raises(ConfigError, match="JWT_SECRET"):
validate_runtime_secrets(_settings(jwt_secret=""))
def test_short_jwt_secret_is_refused():
with pytest.raises(ConfigError, match="JWT_SECRET"):
validate_runtime_secrets(_settings(jwt_secret="x" * (MIN_JWT_SECRET_LENGTH - 1)))
def test_empty_xprv_encryption_key_is_refused():
"""Without it Fernet fails on the first key derivation — i.e. the first time
anyone registers or a transaction needs signing."""
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY"):
validate_runtime_secrets(_settings(xprv_encryption_key=" "))
def test_all_problems_are_reported_at_once():
with pytest.raises(ConfigError) as exc_info:
validate_runtime_secrets(_settings(jwt_secret="", xprv_encryption_key=""))
message = str(exc_info.value)
assert "JWT_SECRET" in message and "XPRV_ENCRYPTION_KEY" in message
def test_an_empty_admin_token_is_not_fatal():
"""require_admin already denies every request when it's unset, so the effect is a
locked admin panel rather than an open one no reason to refuse to boot."""
validate_runtime_secrets(_settings(admin_token=""))
+199 -12
View File
@@ -1,41 +1,83 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
import app.rounds.confirmation # noqa: F401 (registers the "payout" handler)
from sqlalchemy import select
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, Round, RoundParticipant
from app.db.models import PendingTransaction, Round, RoundParticipant, User
from app.electrum.scripthash import address_to_scripthash
from app.tx.confirmation import poll_once
from app.wallet.hd import derive_user_address
class FakeClient:
def __init__(self, confirmations_by_txid: dict[str, int]):
self._confirmations = confirmations_by_txid
"""B-41: poll_once now asks blockchain.scripthash.get_history rather than a
verbose blockchain.transaction.get, so this hands back a flat history
height > 0 means confirmed at that height, 0 (or absent) means still in the
mempool. The scripthash argument is ignored: every candidate's derived
address is looked up against the same known universe of txids, which is
fine since matching happens on tx_hash, not on which address asked."""
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
return {"confirmations": self._confirmations.get(txid, 0)}
def __init__(self, heights_by_txid: dict[str, int]):
self._heights = heights_by_txid
async def get_history(self, scripthash: str) -> list[dict]:
return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()]
@pytest.fixture
async def session_factory():
async def session_factory(tmp_path, monkeypatch):
# own_address_for (B-41) derives each row's address via the HD wallet, so
# poll_once now needs a real master key — same bootstrap test_broadcast.py
# and test_reconcile.py use.
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _make_user(session, derivation_index: int) -> User:
user = User(
username=f"user{derivation_index}",
password_hash="x",
derivation_index=derivation_index,
address=derive_user_address(derivation_index),
)
session.add(user)
await session.flush()
return user
async def test_bet_confirmation_marks_participant_confirmed(session_factory):
async with session_factory() as session:
user = await _make_user(session, 0)
session.add(Round(id=1, status="open"))
session.add(
RoundParticipant(
round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
round_id=1, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
)
)
session.add(
PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")
PendingTransaction(
kind="bet", round_id=1, user_id=user.id, current_txid="tx1", fee_rate_sat_vb=1,
raw_tx_hex="00", status="pending",
)
)
await session.commit()
@@ -53,9 +95,17 @@ async def test_bet_confirmation_marks_participant_confirmed(session_factory):
async def test_unconfirmed_tx_is_left_pending(session_factory):
async with session_factory() as session:
user = await _make_user(session, 0)
session.add(Round(id=2, status="open"))
session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast"))
session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
session.add(
RoundParticipant(round_id=2, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast")
)
session.add(
PendingTransaction(
kind="bet", round_id=2, user_id=user.id, current_txid="tx2", fee_rate_sat_vb=1,
raw_tx_hex="00", status="pending",
)
)
await session.commit()
client = FakeClient({"tx2": 0})
@@ -70,7 +120,11 @@ async def test_unconfirmed_tx_is_left_pending(session_factory):
async def test_payout_confirmation_closes_round(session_factory):
async with session_factory() as session:
session.add(Round(id=3, status="paying_out", payout_txid="tx3"))
session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
session.add(
PendingTransaction(
kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"
)
)
await session.commit()
client = FakeClient({"tx3": 2})
@@ -80,3 +134,136 @@ async def test_payout_confirmation_closes_round(session_factory):
async with session_factory() as session:
round_ = await session.get(Round, 3)
assert round_.status == "closed"
class ExplodingClient:
"""Answers for one address's history and raises for the other's — the
get_history equivalent of a server that no longer knows a particular tx
(dropped from the mempool, replaced by a bump)."""
def __init__(self, heights_by_txid: dict[str, int], exploding_scripthash: str):
self._heights = heights_by_txid
self._exploding = exploding_scripthash
async def get_history(self, scripthash: str) -> list[dict]:
if scripthash == self._exploding:
raise RuntimeError("server error")
return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()]
async def test_one_unresolvable_candidate_does_not_block_the_others(session_factory):
"""B-03: the lookup used to be unguarded, so a single failing candidate aborted
the whole pass nothing confirmed again until an operator intervened, which in
turn meant no round could ever close. B-41 changed the failure unit from "one
txid" to "one address's history", but the isolation guarantee is the same."""
async with session_factory() as session:
good_user = await _make_user(session, 0)
gone_user = await _make_user(session, 1)
session.add(Round(id=10, status="open"))
session.add(
RoundParticipant(
round_id=10, user_id=good_user.id, bet_amount_sats=1_000, bet_txid="good", status="broadcast"
)
)
session.add(
PendingTransaction(
kind="bet", round_id=10, user_id=gone_user.id, current_txid="gone", fee_rate_sat_vb=1,
raw_tx_hex="00", status="pending",
)
)
session.add(
PendingTransaction(
kind="bet", round_id=10, user_id=good_user.id, current_txid="good", fee_rate_sat_vb=1,
raw_tx_hex="00", status="pending",
)
)
await session.commit()
exploding_scripthash = address_to_scripthash(derive_user_address(1))
confirmed = await poll_once(
session_factory, ExplodingClient({"good": 1}, exploding_scripthash=exploding_scripthash)
)
assert confirmed == 1 # the healthy one still got processed
async with session_factory() as session:
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 10))).one()
assert participant.status == "confirmed"
rows = {p.current_txid: p.status for p in (await session.scalars(select(PendingTransaction))).all()}
assert rows["good"] == "confirmed"
assert rows["gone"] == "pending" # left for the reconciler to judge, not abandoned here
async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
"""B-02: the handler used to match on bet_txid, so a bumped bet confirmed under
a txid no participant carried the participant stayed "broadcast" forever and
the round could never close. It now resolves by (round_id, user_id)."""
async with session_factory() as session:
user = await _make_user(session, 0)
session.add(Round(id=11, status="open"))
session.add(
RoundParticipant(
round_id=11, user_id=user.id, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast"
)
)
session.add(
PendingTransaction(
kind="bet", round_id=11, user_id=user.id, current_txid="bumped-txid", fee_rate_sat_vb=2,
raw_tx_hex="00", status="pending", replaced_by_txid="old-txid",
)
)
await session.commit()
assert await poll_once(session_factory, FakeClient({"bumped-txid": 1})) == 1
async with session_factory() as session:
participant = (await session.scalars(select(RoundParticipant).where(RoundParticipant.round_id == 11))).one()
assert participant.status == "confirmed"
async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factory):
async with session_factory() as session:
session.add(Round(id=12, status="paying_out", payout_txid="old-payout"))
session.add(
PendingTransaction(
kind="payout", round_id=12, current_txid="bumped-payout", fee_rate_sat_vb=2,
raw_tx_hex="00", status="pending",
)
)
await session.commit()
assert await poll_once(session_factory, FakeClient({"bumped-payout": 1})) == 1
async with session_factory() as session:
assert (await session.get(Round, 12)).status == "closed"
async def test_poll_once_caches_history_per_scripthash(session_factory):
"""Two pending bets from the same user share one address — fetching its
history twice in one pass would be wasteful."""
async with session_factory() as session:
user = await _make_user(session, 0)
session.add(Round(id=20, status="open"))
session.add(
PendingTransaction(
kind="bet", round_id=20, user_id=user.id, current_txid="tx-a", fee_rate_sat_vb=1,
raw_tx_hex="00", status="pending",
)
)
session.add(
PendingTransaction(
kind="withdrawal", user_id=user.id, current_txid="tx-b", fee_rate_sat_vb=1,
raw_tx_hex="00", status="pending",
)
)
await session.commit()
call_count = {"n": 0}
class CountingClient:
async def get_history(self, scripthash: str) -> list[dict]:
call_count["n"] += 1
return [{"tx_hash": "tx-a", "height": 0}, {"tx_hash": "tx-b", "height": 0}]
await poll_once(session_factory, CountingClient())
assert call_count["n"] == 1
+61
View File
@@ -0,0 +1,61 @@
"""Regression tests for B-39: SQLite must run in WAL mode with a busy_timeout,
since this app has five concurrent background tasks plus every HTTP handler
sharing one database file, and the default rollback-journal mode lets a writer
block every reader and fails a second writer immediately instead of waiting."""
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from app.db.base import _SQLITE_BUSY_TIMEOUT_MS, _register_sqlite_pragmas
@pytest.fixture
async def sqlite_engine(tmp_path):
# WAL needs a real file (it writes a companion -wal/-shm file alongside it) —
# ":memory:" wouldn't exercise the same path.
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/test.db")
yield engine
await engine.dispose()
async def _pragma(engine, name: str):
async with engine.connect() as conn:
result = await conn.exec_driver_sql(f"PRAGMA {name}")
return result.fetchone()[0]
async def test_register_sqlite_pragmas_enables_wal_and_busy_timeout(sqlite_engine):
_register_sqlite_pragmas(sqlite_engine)
assert (await _pragma(sqlite_engine, "journal_mode")).lower() == "wal"
assert await _pragma(sqlite_engine, "busy_timeout") == _SQLITE_BUSY_TIMEOUT_MS
assert await _pragma(sqlite_engine, "synchronous") == 1 # NORMAL
async def test_register_sqlite_pragmas_applies_to_every_new_connection(sqlite_engine):
"""The pool can open more than one underlying DBAPI connection over the
engine's lifetime — the pragmas must be re-applied to each one, not just
the first, or a later connection would silently fall back to SQLite's
defaults."""
_register_sqlite_pragmas(sqlite_engine)
async with sqlite_engine.connect() as first:
await first.exec_driver_sql("PRAGMA journal_mode")
async with sqlite_engine.connect() as second:
result = await second.exec_driver_sql("PRAGMA busy_timeout")
assert result.fetchone()[0] == _SQLITE_BUSY_TIMEOUT_MS
def test_register_sqlite_pragmas_is_a_noop_for_other_dialects():
"""Must not touch (or crash on) a non-sqlite engine — e.g. a future
PostgreSQL DATABASE_URL, which neither needs nor understands these
pragmas."""
class _FakeDialect:
name = "postgresql"
class _FakeEngine:
dialect = _FakeDialect()
_register_sqlite_pragmas(_FakeEngine()) # must not raise
+98
View File
@@ -0,0 +1,98 @@
"""Regression tests for B-30: a periodic sweep must catch a deposit whose
scripthash notification was silently lost, independent of whatever the
notification-driven path (electrum/listener.py:refresh_user) is doing."""
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import User
from app.deposits.reconcile import DepositReconciler
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def _seed_users(session_factory, addresses: list[str]) -> list[int]:
async with session_factory() as session:
ids = []
for i, address in enumerate(addresses):
user = User(username=f"user{i}", password_hash="x", derivation_index=i, address=address)
session.add(user)
await session.flush()
ids.append(user.id)
await session.commit()
return ids
# Real, decodable PLM bech32 addresses (address_to_scripthash actually parses
# them) — arbitrary otherwise.
_ADDRESSES = [
"plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd",
"plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n",
"plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx",
]
class FakeListener:
def __init__(self, *, fail_for: set[int] | None = None, disconnect_after: int | None = None):
self.client = object() # truthy: "connected"
self.refreshed: list[int] = []
self._fail_for = fail_for or set()
self._disconnect_after = disconnect_after
async def refresh_user(self, user_id: int, scripthash: str) -> None:
self.refreshed.append(user_id)
if self._disconnect_after is not None and len(self.refreshed) >= self._disconnect_after:
self.client = None
if user_id in self._fail_for:
raise RuntimeError(f"listunspent failed for user {user_id}")
async def test_sweep_once_refreshes_every_user(session_factory):
user_ids = await _seed_users(session_factory, _ADDRESSES)
listener = FakeListener()
reconciler = DepositReconciler(session_factory, listener)
await reconciler._sweep_once()
assert listener.refreshed == user_ids
async def test_sweep_once_continues_past_a_failing_user(session_factory):
"""One user's refresh failing (a transient network hiccup) must not stop the
sweep from reaching the rest mirrors poll_once's per-item isolation."""
user_ids = await _seed_users(session_factory, _ADDRESSES)
listener = FakeListener(fail_for={user_ids[1]})
reconciler = DepositReconciler(session_factory, listener)
await reconciler._sweep_once()
assert listener.refreshed == user_ids
async def test_sweep_once_stops_when_the_connection_drops_mid_sweep(session_factory):
"""No point continuing once the connection is gone — the next reconnect's own
_subscribe_all_users will cover everyone anyway."""
user_ids = await _seed_users(session_factory, _ADDRESSES)
listener = FakeListener(disconnect_after=1)
reconciler = DepositReconciler(session_factory, listener)
await reconciler._sweep_once()
assert listener.refreshed == user_ids[:1]
async def test_sweep_once_does_nothing_with_no_users(session_factory):
listener = FakeListener()
reconciler = DepositReconciler(session_factory, listener)
await reconciler._sweep_once() # must not raise
assert listener.refreshed == []
+134 -2
View File
@@ -1,9 +1,15 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
from app.db.models import AuditLog, User, UtxoEvent
from app.deposits.service import (
credit_confirmed_utxos,
find_utxos_missing_from,
mark_utxos_spent_externally,
reinstate_reappeared_utxos,
)
@pytest.fixture
@@ -52,3 +58,129 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id):
assert first == 1
assert second == 0
assert user.cached_balance_sats == 7_000_000
# --- B-29: detecting a UTXO spent outside the platform is now a three-step,
# corroborate-before-you-mark process, split across find_utxos_missing_from
# (read-only candidate detection), the caller's own corroboration against other
# servers (electrum/listener.py, not exercised here), and mark_utxos_spent_
# externally (persistence only, once a candidate is already confirmed). ---------
async def test_find_utxos_missing_from_returns_the_missing_candidate(session_factory, user_id):
async with session_factory() as session:
await credit_confirmed_utxos(
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
)
# A different outpoint present in this refresh — our own tracked one is
# genuinely absent from it, not just from an entirely empty reply.
other_entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}]
async with session_factory() as session:
candidates = await find_utxos_missing_from(session, user_id, other_entries)
assert len(candidates) == 1
assert candidates[0].txid == "dd" * 32
assert candidates[0].spent_txid is None # read-only: nothing is marked yet
async def test_find_utxos_missing_from_returns_nothing_when_present(session_factory, user_id):
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
async with session_factory() as session:
await credit_confirmed_utxos(session, user_id, entries)
async with session_factory() as session:
candidates = await find_utxos_missing_from(session, user_id, entries)
assert candidates == []
user = await session.get(User, user_id)
assert user.cached_balance_sats == 3_000_000
async def test_find_utxos_missing_from_skips_a_totally_empty_response(session_factory, user_id):
"""B-29: an entirely empty listunspent for a funded address reads as an
incomplete/broken response, not proof of a full external sweep it would
otherwise flag every UTXO of this user as missing from one bad reply."""
async with session_factory() as session:
await credit_confirmed_utxos(
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
)
async with session_factory() as session:
candidates = await find_utxos_missing_from(session, user_id, [])
assert candidates == []
async def test_mark_utxos_spent_externally_marks_and_corrects_balance(session_factory, user_id):
async with session_factory() as session:
await credit_confirmed_utxos(
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
)
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
marked = await mark_utxos_spent_externally(session, user_id, [utxo.id])
assert marked == 1
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid == "external-spend"
audit_events = (await session.scalars(select(AuditLog))).all()
assert any(e.event_type == "utxo_spent_externally" for e in audit_events)
async def test_mark_utxos_spent_externally_skips_an_already_resolved_row(session_factory, user_id):
"""Something else (a legitimate platform spend, or a prior refresh) may have
resolved the row between the caller reading the candidate list and finishing
corroboration mark_utxos_spent_externally must not clobber that."""
async with session_factory() as session:
await credit_confirmed_utxos(
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
)
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
utxo_id = utxo.id
utxo.spent_txid = "some-real-platform-txid"
await session.commit()
async with session_factory() as session:
marked = await mark_utxos_spent_externally(session, user_id, [utxo_id])
assert marked == 0
utxo = await session.get(UtxoEvent, utxo_id)
assert utxo.spent_txid == "some-real-platform-txid" # untouched
async def test_reinstate_reappeared_utxos_clears_the_mark_and_restores_balance(session_factory, user_id):
async with session_factory() as session:
await credit_confirmed_utxos(
session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
)
utxo_id = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().id
async with session_factory() as session:
await mark_utxos_spent_externally(session, user_id, [utxo_id])
# The outpoint reappears as unspent in a later refresh.
entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}]
async with session_factory() as session:
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
assert reinstated == 1
user = await session.get(User, user_id)
assert user.cached_balance_sats == 20_000_000
utxo = await session.get(UtxoEvent, utxo_id)
assert utxo.spent_txid is None
audit_events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "utxo_external_spend_reinstated" in audit_events
async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory, user_id):
entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}]
async with session_factory() as session:
await credit_confirmed_utxos(session, user_id, entries)
async with session_factory() as session:
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
assert reinstated == 0
+28
View File
@@ -0,0 +1,28 @@
"""B-42: Swagger/ReDoc/OpenAPI JSON must not be reachable unless explicitly enabled —
they enumerate the whole API surface, admin endpoints included."""
import importlib
from app.config import settings
def _reload_main():
import app.main
return importlib.reload(app.main)
def test_docs_disabled_by_default(monkeypatch):
monkeypatch.setattr(settings, "enable_api_docs", False)
main = _reload_main()
assert main.app.docs_url is None
assert main.app.redoc_url is None
assert main.app.openapi_url is None
def test_docs_enabled_when_configured(monkeypatch):
monkeypatch.setattr(settings, "enable_api_docs", True)
main = _reload_main()
assert main.app.docs_url == "/docs"
assert main.app.redoc_url == "/redoc"
assert main.app.openapi_url == "/openapi.json"
+18
View File
@@ -0,0 +1,18 @@
"""B-44: README and docs/running-the-server.md must not document a bare
`uvicorn --reload` workflow the server always runs via Docker, in dev and
production alike (CLAUDE.md's "Commands" section), and the two files had
drifted back to contradicting that policy."""
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.parent
def test_readme_has_no_bare_uvicorn_command():
readme = (REPO_ROOT / "README.md").read_text()
assert "uvicorn app.main:app --reload" not in readme
def test_running_the_server_doc_has_no_bare_uvicorn_command():
doc = (REPO_ROOT / "docs" / "running-the-server.md").read_text()
assert "uvicorn app.main:app --reload" not in doc
+41 -10
View File
@@ -1,19 +1,50 @@
import pytest
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.draw import (
draw_winner,
header_hex_to_block_hash,
header_meets_its_own_target,
header_prev_hash,
)
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
# cross-checked against the blockhash reported by blockchain.transaction.get for a
# tx confirmed in that block.
_REAL_HEADER_HEX = (
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
"e6a47a61b1ad8012582"
)
def test_header_hex_to_block_hash_matches_known_mainnet_block():
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
# cross-checked against the blockhash reported by blockchain.transaction.get
# for a tx confirmed in that block.
header_hex = (
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
"e6a47a61b1ad8012582"
)
known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86"
assert header_hex_to_block_hash(header_hex) == known_block_hash
assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash
def test_header_meets_its_own_target_accepts_a_real_mined_header():
"""B-28: a genuinely mined mainnet header must pass its own self-consistency
check this isn't just a synthetic-header property."""
assert header_meets_its_own_target(_REAL_HEADER_HEX) is True
def test_header_meets_its_own_target_rejects_a_tampered_header():
"""Flipping a single nonce bit changes the hash completely (avalanche effect)
without changing the claimed difficulty, so a tampered-but-otherwise-real
header should almost certainly fail this is what would catch a
hostile/MITM'd server replaying a real header with a doctored field."""
tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX))
tampered[-1] ^= 0xFF # flip the last byte of the nonce
assert header_meets_its_own_target(tampered.hex()) is False
def test_header_meets_its_own_target_rejects_wrong_length():
assert header_meets_its_own_target("aa" * 10) is False
def test_header_prev_hash_matches_the_known_previous_block():
# Block 477486's predecessor, 477485 — independently known from the same chain.
assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb"
def test_draw_winner_is_deterministic_and_within_range():
+80
View File
@@ -75,3 +75,83 @@ async def test_notification_delivered_to_subscription_queue():
assert params == ["abcd", "newstatus"]
client._read_task.cancel()
async def test_request_times_out_instead_of_hanging_forever(monkeypatch):
"""B-01: a server that owes us a reply and never sends one used to hang the
caller permanently which meant a POST /bets could hang while holding the
per-user lock, and the confirmation poller could stop polling for good."""
from app.electrum import client as client_module
monkeypatch.setattr(client_module, "_REQUEST_TIMEOUT_SECONDS", 0.05)
client, reader, writer = await _client_with_fake_transport()
try:
await client.request("blockchain.transaction.get", ["deadbeef"])
assert False, "expected ElectrumError"
except ElectrumError as exc:
assert "timed out" in str(exc)
# The connection is torn down, so callers stop reusing a server that owes us.
assert client._closed.is_set()
client._read_task.cancel()
async def test_wait_closed_resolves_when_the_read_loop_dies():
"""B-01: the read loop dying used to be invisible — the listener sat on its
notification queues forever and never reconnected."""
client, reader, writer = await _client_with_fake_transport()
waiter = asyncio.create_task(client.wait_closed())
reader.feed_eof() # peer closed the connection
await asyncio.wait_for(waiter, timeout=1)
client._read_task.cancel()
async def test_pending_request_fails_when_the_connection_drops():
client, reader, writer = await _client_with_fake_transport()
task = asyncio.create_task(client.request("server.ping"))
await asyncio.sleep(0)
reader.feed_eof()
try:
await asyncio.wait_for(task, timeout=1)
assert False, "expected ElectrumError"
except ElectrumError:
pass
def test_parse_endpoints_puts_the_primary_first_and_dedupes():
from app.electrum.client import ElectrumEndpoint, parse_endpoints
endpoints = parse_endpoints(
"primary.example", 50002, True, "second.example:50002, third.example:50001:notls, primary.example:50002"
)
assert endpoints == [
ElectrumEndpoint("primary.example", 50002, True),
ElectrumEndpoint("second.example", 50002, True),
ElectrumEndpoint("third.example", 50001, False),
]
def test_parse_endpoints_handles_an_empty_fallback_list():
from app.electrum.client import ElectrumEndpoint, parse_endpoints
assert parse_endpoints("only.example", 50002, True, "") == [ElectrumEndpoint("only.example", 50002, True)]
assert parse_endpoints("only.example", 50002, True, " , ") == [
ElectrumEndpoint("only.example", 50002, True)
]
def test_parse_endpoints_rejects_a_malformed_entry():
"""A typo in a fallback server must fail at startup, not during the outage when
the fallback is the thing that's needed."""
import pytest
from app.electrum.client import parse_endpoints
for bad in ["nohost:", "host:notaport", "host", "host:50002:weird"]:
with pytest.raises(ValueError):
parse_endpoints("primary.example", 50002, True, bad)
+632
View File
@@ -0,0 +1,632 @@
"""Listener-level behaviour: server rotation on failure (the fallback-servers
feature), the chain-tip monotonicity guard (B-19), header validation and
multi-server corroboration (B-28), the new-user subscribe task's retention and
error logging (B-30), and bounded-concurrency, non-blocking resubscribe on
reconnect (B-31).
The reconnect loop itself (B-01) is covered from the client side in
test_electrum_client.py what's asserted here is that the listener *acts* on a
dead connection by moving to the next server instead of retrying the same one.
"""
import asyncio
import logging
import struct
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import User, UtxoEvent
from app.electrum.client import ElectrumEndpoint
from app.electrum.listener import ElectrumListener
from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
_ENDPOINTS = [
ElectrumEndpoint("first.example", 50002, True),
ElectrumEndpoint("second.example", 50002, True),
ElectrumEndpoint("third.example", 50001, False),
]
# A regtest-style trivial difficulty target (~50% of hashes satisfy it), so mining
# a real, self-consistent test header takes a handful of nonce attempts rather than
# needing actual mainnet-grade hashpower. Not a valid PLM mainnet difficulty —
# irrelevant here, since header_meets_its_own_target only checks self-consistency.
_EASY_BITS = 0x207FFFFF
def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str:
return (
struct.pack("<I", 1) # version
+ bytes.fromhex(prev_hash_hex)[::-1]
+ bytes.fromhex("00" * 32) # merkle_root, irrelevant to the checks under test
+ struct.pack("<I", 0) # timestamp
+ struct.pack("<I", bits)
+ struct.pack("<I", nonce)
).hex()
def _mine_header(prev_hash_hex: str, *, bits: int = _EASY_BITS) -> str:
"""A real header that satisfies its own claimed target — good enough to
exercise header_meets_its_own_target/_apply_header for real, without needing
genuine PLM-mainnet-grade hashpower."""
for nonce in range(100_000):
header_hex = _build_header(prev_hash_hex, nonce, bits=bits)
if header_meets_its_own_target(header_hex):
return header_hex
raise RuntimeError("failed to mine a test header within the attempt budget")
async def test_rotates_to_the_next_server_after_a_failed_session(session_factory):
"""One unreachable server should cost a single attempt, not an outage: every
deposit credit, broadcast and confirmation goes through this one connection."""
attempted: list[str] = []
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
async def failing_run_once(endpoint):
attempted.append(endpoint.host)
if len(attempted) >= 5:
raise asyncio.CancelledError # stop the loop
raise ConnectionRefusedError("nope")
listener._run_once = failing_run_once
with pytest.raises(asyncio.CancelledError):
await listener.run()
# Round-robin over all three, wrapping around — never the same one twice in a row.
assert attempted == [
"first.example",
"second.example",
"third.example",
"first.example",
"second.example",
]
async def test_backoff_only_sleeps_after_every_server_has_been_tried(session_factory, monkeypatch):
"""A genuinely offline network must back off, but not before the alternatives have
had their turn."""
sleeps: list[float] = []
async def fake_sleep(seconds):
sleeps.append(seconds)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
attempts = {"n": 0}
async def failing_run_once(endpoint):
attempts["n"] += 1
if attempts["n"] > 6:
raise asyncio.CancelledError
raise ConnectionRefusedError("nope")
listener._run_once = failing_run_once
with pytest.raises(asyncio.CancelledError):
await listener.run()
# 6 failures over 3 servers = 2 completed cycles = 2 sleeps, growing.
assert sleeps == [1, 2]
async def test_a_connected_session_resets_the_backoff(session_factory, monkeypatch):
sleeps: list[float] = []
async def fake_sleep(seconds):
sleeps.append(seconds)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
outcomes = iter([False, False, False, True, False, False, False])
async def run_once(endpoint):
try:
connected = next(outcomes)
except StopIteration:
raise asyncio.CancelledError from None
if not connected:
raise ConnectionRefusedError("nope")
return True # connected, then dropped
listener._run_once = run_once
with pytest.raises(asyncio.CancelledError):
await listener.run()
# First cycle of 3 failures sleeps 1s; the successful connection resets the
# counter, so the next 3 failures sleep 1s again rather than 2s.
assert sleeps == [1, 1]
async def test_listener_with_no_endpoints_gives_up_loudly(session_factory):
listener = ElectrumListener(lambda endpoint: None, session_factory, [])
await listener.run() # returns instead of spinning or crashing
assert listener.current_endpoint is None
# --- B-30: address_for_new_user's subscribe task must be retained (not fire-and-
# forget) and its failure must be observable, not silently swallowed. -------------
async def test_address_for_new_user_does_nothing_without_a_connection(session_factory):
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") # listener.client is None
assert listener._background_tasks == set()
async def test_address_for_new_user_retains_and_logs_a_failed_subscribe_task(session_factory, caplog):
"""Before B-30, this task was fire-and-forget: an AssertionError (self.client
turning None mid-flight) or any other failure vanished into asyncio's default
unretrieved-exception handler instead of being logged anywhere the operator
could see, and nothing kept the task alive in the meantime."""
class FailingClient:
async def subscribe_scripthash(self, scripthash):
raise ConnectionResetError("dropped mid-subscribe")
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
listener.client = FailingClient()
listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
assert len(listener._background_tasks) == 1 # retained while in flight
with caplog.at_level(logging.WARNING):
await asyncio.gather(*list(listener._background_tasks), return_exceptions=True)
await asyncio.sleep(0) # let the done_callbacks (scheduled via call_soon) run
assert listener._background_tasks == set() # discarded once done
assert "could not subscribe" in caplog.text
def test_tip_never_moves_backwards(session_factory):
"""B-19: `self.tip_height = header["height"]` accepted a lower height, and
_wait_for_next_block waits for tip_height > tip_at_close so a regression
silently added a block to the draw's wait. The hash must not move either: it's
the draw's entropy source, and a mismatched height/hash pair would be worse than
a stale one."""
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
# A lower height is ignored purely on height, before any header validation even
# runs — reorg or server switch, not a real advance.
listener._apply_header({"height": 99, "hex": "bb"})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100)
header_101 = _mine_header(header_hex_to_block_hash(header_100))
listener._apply_header({"height": 101, "hex": header_101})
assert (listener.tip_height, listener.tip_header_hex) == (101, header_101)
# --- B-28: a hostile or MITM'd server can no longer single-handedly decide the
# draw's entropy — header self-consistency/linkage checks, and multi-server
# corroboration for the block the draw actually uses. ---------------------------
def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory):
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
# Real mainnet-grade difficulty (genesis-era Bitcoin bits): satisfying it by
# chance is astronomically unlikely, so this header is self-inconsistent.
forged = _build_header("00" * 32, nonce=0, bits=0x1D00FFFF)
with pytest.raises(HeaderValidationError):
listener._apply_header({"height": 100, "hex": forged})
assert (listener.tip_height, listener.tip_header_hex) == (0, None) # untouched
def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_factory):
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
# A single-block advance (101 = 100 + 1) whose prev_block claims an unrelated
# chain — well-formed and self-consistently mined, but not actually built on
# top of our current tip.
disconnected = _mine_header("ff" * 32)
with pytest.raises(HeaderValidationError):
listener._apply_header({"height": 101, "hex": disconnected})
assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched
def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory):
"""A reconnect (or the very first header of a session) hands us whatever the
server's current tip is — which is legitimately not a single-block advance
from whatever we last saw. There's no full header chain to check linkage
against in that case, so only self-consistency is enforced."""
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
header_100 = _mine_header("00" * 32)
listener._apply_header({"height": 100, "hex": header_100})
header_150 = _mine_header("ff" * 32) # unrelated prev_block, height jumps by 50
listener._apply_header({"height": 150, "hex": header_150}) # must not raise
assert (listener.tip_height, listener.tip_header_hex) == (150, header_150)
async def _endpoint_client_factory(responses: dict[str, object]):
"""Builds a client_factory whose fake clients answer blockchain.block.header
per-endpoint according to `responses`: a header hex string to agree/disagree
with, `None` to simulate an unreachable server, or an Exception instance to
simulate a request failure."""
class _FakeClient:
def __init__(self, answer):
self._answer = answer
self.closed = False
async def connect(self):
if isinstance(self._answer, Exception):
raise self._answer
async def request(self, method, params):
assert method == "blockchain.block.header"
if self._answer is None:
raise ConnectionRefusedError("unreachable")
return self._answer
async def close(self):
self.closed = True
def factory(endpoint):
return _FakeClient(responses[endpoint.host])
return factory
async def test_corroborate_header_true_with_no_other_servers_configured(session_factory):
single = [ElectrumEndpoint("only.example", 50002, True)]
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
assert await listener.corroborate_header(100, "deadbeef") is True
async def test_corroborate_header_true_when_others_agree(session_factory):
header_hex = _mine_header("00" * 32)
expected_hash = header_hex_to_block_hash(header_hex)
factory = await _endpoint_client_factory(
{"first.example": header_hex, "second.example": header_hex, "third.example": header_hex}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_header(100, expected_hash) is True
async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory):
"""The active connection is exactly what a hostile server or a MITM would
control corroborating against it too would defeat the point."""
header_hex = _mine_header("00" * 32)
expected_hash = header_hex_to_block_hash(header_hex)
# first.example (the active endpoint) would raise if ever queried.
factory = await _endpoint_client_factory(
{"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert listener.current_endpoint.host == "first.example"
assert await listener.corroborate_header(100, expected_hash) is True
async def test_corroborate_header_false_when_majority_disagrees(session_factory):
header_hex = _mine_header("00" * 32)
expected_hash = header_hex_to_block_hash(header_hex)
disagreeing_hex = _mine_header("11" * 32)
factory = await _endpoint_client_factory(
{"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_header(100, expected_hash) is False
async def test_corroborate_header_false_when_nobody_responds(session_factory):
factory = await _endpoint_client_factory(
{"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_header(100, "deadbeef") is False
# --- B-29: a UTXO absent from our own connection's listunspent must be
# corroborated by other configured servers before it's treated as genuinely spent
# outside the platform. ------------------------------------------------------------
async def _listunspent_client_factory(responses: dict[str, object]):
"""Builds a client_factory whose fake clients answer listunspent per-endpoint:
a list of entries to report as unspent, `None` to simulate an unreachable
server (fails at listunspent), or an Exception instance to simulate a connect
failure."""
class _FakeClient:
def __init__(self, answer):
self._answer = answer
async def connect(self):
if isinstance(self._answer, Exception):
raise self._answer
async def listunspent(self, scripthash):
if self._answer is None:
raise ConnectionRefusedError("unreachable")
return self._answer
async def close(self):
pass
def factory(endpoint):
return _FakeClient(responses[endpoint.host])
return factory
async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory):
single = [ElectrumEndpoint("only.example", 50002, True)]
listener = ElectrumListener(lambda endpoint: None, session_factory, single)
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
async def test_corroborate_utxo_spent_true_when_others_agree_its_gone(session_factory):
factory = await _listunspent_client_factory({"first.example": [], "second.example": [], "third.example": []})
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True
async def test_corroborate_utxo_spent_false_when_majority_still_see_it_unspent(session_factory):
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}]
factory = await _listunspent_client_factory(
{"first.example": [], "second.example": still_there, "third.example": still_there}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
async def test_corroborate_utxo_spent_false_when_nobody_responds(session_factory):
factory = await _listunspent_client_factory(
{"first.example": [], "second.example": None, "third.example": ConnectionRefusedError("down")}
)
listener = ElectrumListener(factory, session_factory, _ENDPOINTS)
assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False
class _ActiveClient:
"""Stands in for `self.client`, the listener's one active connection —
refresh_user only ever calls listunspent on it."""
def __init__(self, entries: list[dict]):
self._entries = entries
async def listunspent(self, scripthash):
return self._entries
async def _seed_funded_user(session_factory, *, username: str, address: str) -> int:
from app.wallet.balance import recompute_balance
async with session_factory() as session:
user = User(username=username, password_hash="x", derivation_index=0, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(user_id=user.id, txid="dd" * 32, vout=0, amount_sats=20_000_000, confirmed_height=100)
)
await recompute_balance(session, user.id)
await session.commit()
return user.id
# An unrelated outpoint present alongside our own connection's listunspent reply —
# keeps `entries` non-empty so find_utxos_missing_from's "entirely empty response"
# guard doesn't swallow these tests; our own tracked UTXO is still genuinely
# absent from it.
_UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}]
async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory):
user_id = await _seed_funded_user(session_factory, username="bob", address="plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
others_factory = await _listunspent_client_factory(
{"first.example": [], "second.example": [], "third.example": []}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
await listener.refresh_user(user_id, "scripthash")
async with session_factory() as session:
utxo = (
await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32))
).one()
assert utxo.spent_txid == "external-spend"
user = await session.get(User, user_id)
# The original 20_000_000 is spent; the unrelated entry the "active"
# connection also reported gets freshly credited alongside it.
assert user.cached_balance_sats == 1_000_000
async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory):
"""The single most important case: our own connection alone reporting the
UTXO missing must not be enough before B-29 this zeroed the balance on one
bad reply."""
user_id = await _seed_funded_user(session_factory, username="carol", address="plm1qtest2")
still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}]
others_factory = await _listunspent_client_factory(
{"first.example": [], "second.example": still_there, "third.example": still_there}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.client = _ActiveClient(_UNRELATED_ENTRY)
await listener.refresh_user(user_id, "scripthash")
async with session_factory() as session:
utxo = (
await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32))
).one()
assert utxo.spent_txid is None
user = await session.get(User, user_id)
# Untouched, plus the unrelated entry credited alongside it.
assert user.cached_balance_sats == 21_000_000
# --- B-31: resubscribing on reconnect must be bounded-concurrency and must not
# block tip updates (and so an in-flight draw) for its entire duration. -----------
def _fake_address(i: int) -> str:
"""A real, decodable PLM bech32 P2WPKH address (address_to_scripthash
actually parses it) distinct per index, since User.address is unique."""
from embit import script
from app.wallet.plm_network import PLM_MAINNET
payload = (i + 1).to_bytes(20, "big")
return script.Script(b"\x00\x14" + payload).address(network=PLM_MAINNET)
async def _seed_users(session_factory, count: int) -> None:
async with session_factory() as session:
for i in range(count):
session.add(
User(username=f"user{i}", password_hash="x", derivation_index=i, address=_fake_address(i))
)
await session.commit()
async def test_subscribe_all_users_bounds_concurrency(session_factory):
"""B-31: at thousands of users, subscribing one at a time meant thousands of
sequential round-trips. Concurrency must be bounded (not unlimited either
a huge user base shouldn't open thousands of simultaneous requests)."""
user_count = 45
await _seed_users(session_factory, user_count)
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
in_flight = 0
max_in_flight = 0
calls = []
async def fake_subscribe_and_refresh(scripthash, user_id):
nonlocal in_flight, max_in_flight
in_flight += 1
max_in_flight = max(max_in_flight, in_flight)
calls.append(user_id)
await asyncio.sleep(0) # yield, so genuinely-concurrent calls interleave
in_flight -= 1
listener._subscribe_and_refresh = fake_subscribe_and_refresh
await listener._subscribe_all_users()
assert len(calls) == user_count
assert 1 < max_in_flight <= 20 # bounded, and actually concurrent (not serial)
async def test_subscribe_all_users_continues_past_a_failing_user(session_factory):
await _seed_users(session_factory, 5)
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
succeeded = []
async def flaky_subscribe_and_refresh(scripthash, user_id):
if user_id == 3:
raise ConnectionResetError("dropped mid-subscribe")
succeeded.append(user_id)
listener._subscribe_and_refresh = flaky_subscribe_and_refresh
await listener._subscribe_all_users() # must not raise
assert succeeded == [1, 2, 4, 5]
class _FakeConnectClient:
"""A minimally-real ElectrumClient double: enough of connect/subscribe/notify/
ping/wait_closed/close to drive ElectrumListener._run_once end-to-end."""
def __init__(self, header: dict):
self._header = header
self._queues: dict[str, asyncio.Queue] = {}
self._closed = asyncio.Event()
async def connect(self):
pass
async def subscribe_headers(self):
return self._header
def notifications(self, method: str) -> asyncio.Queue:
return self._queues.setdefault(method, asyncio.Queue())
async def ping(self):
pass
async def wait_closed(self):
await self._closed.wait()
async def close(self):
self._closed.set()
async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01) -> None:
async def _poll():
while not predicate():
await asyncio.sleep(interval)
await asyncio.wait_for(_poll(), timeout=timeout)
async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory):
"""The core B-31 fix: before this, _subscribe_all_users ran to completion
*before* the header-consuming task even started, so a reconnect with many
users froze tip_height and so _wait_for_next_block's draw wait — for the
entire resubscribe. It must now keep advancing while resubscribing is still
in flight."""
await _seed_users(session_factory, 3)
header_hex = _mine_header("00" * 32)
client = _FakeConnectClient({"height": 100, "hex": header_hex})
listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS)
subscribe_started = asyncio.Event()
async def blocked_subscribe_and_refresh(scripthash, user_id):
subscribe_started.set()
await asyncio.sleep(3600) # simulates a slow sweep; cancelled on cleanup
listener._subscribe_and_refresh = blocked_subscribe_and_refresh
run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0]))
try:
await asyncio.wait_for(subscribe_started.wait(), timeout=2)
# Resubscribing is still stuck mid-flight — but a new tip must still be
# processed, proving the header consumer isn't blocked behind it.
headers_queue = client.notifications("blockchain.headers.subscribe")
next_header_hex = _mine_header(header_hex_to_block_hash(header_hex))
await headers_queue.put([{"height": 101, "hex": next_header_hex}])
await _wait_until(lambda: listener.tip_height == 101)
assert listener.tip_header_hex == next_header_hex
finally:
await client.close()
await run_once_task
+16
View File
@@ -0,0 +1,16 @@
"""B-47: raw_tx_hex (a full raw signed transaction hex) and payload_json (an
arbitrary audit payload) must stay `Text`, not a bare `String`/`VARCHAR` with
no length -- SQLite and PostgreSQL accept that, but other backends (e.g.
MySQL) require a length on VARCHAR and would reject it."""
from sqlalchemy import Text
from app.db.models import AuditLog, PendingTransaction
def test_pending_transaction_raw_tx_hex_is_text():
assert isinstance(PendingTransaction.__table__.c.raw_tx_hex.type, Text)
def test_audit_log_payload_json_is_text():
assert isinstance(AuditLog.__table__.c.payload_json.type, Text)
+55
View File
@@ -0,0 +1,55 @@
"""B-41: own_address_for is the single place tx/confirmation.py and
tx/reconcile.py derive a PendingTransaction's own address from — a payout's
address must always be the pool's, everything else the actual user's."""
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import User
from app.tx.pending_address import own_address_for
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def test_payout_uses_the_pool_address_regardless_of_user_id(session_factory):
from app.wallet.hd import derive_pool_address
async with session_factory() as session:
address = await own_address_for(session, "payout", None)
assert address == derive_pool_address()
@pytest.mark.parametrize("kind", ["bet", "withdrawal"])
async def test_bet_and_withdrawal_use_the_users_own_address(session_factory, kind):
from app.wallet.hd import derive_user_address
async with session_factory() as session:
user = User(username="alice", password_hash="x", derivation_index=3, address=derive_user_address(3))
session.add(user)
await session.flush()
address = await own_address_for(session, kind, user.id)
assert address == derive_user_address(3)
+90
View File
@@ -1,9 +1,11 @@
import pytest
from embit import script
from embit.bip32 import HDKey
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
MAX_TX_INPUTS,
InsufficientFundsError,
Utxo,
build_signed_transaction,
@@ -35,6 +37,22 @@ def test_select_utxos_raises_when_insufficient():
select_utxos(utxos, target_sats=10_000_000)
def test_select_utxos_never_exceeds_the_input_cap(): # B-48
# 200 dust-ish UTXOs that together cover the target, but only past the cap.
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
with pytest.raises(InsufficientFundsError) as excinfo:
select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS + 1)
assert excinfo.value.code == "too_many_inputs"
assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS}
def test_select_utxos_allows_exactly_the_input_cap():
utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)]
selected, total = select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS)
assert len(selected) == MAX_TX_INPUTS
assert total == 100_000 * MAX_TX_INPUTS
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
@@ -111,3 +129,75 @@ def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
change_address=my_address,
fee_rate_sat_vb=1,
)
def test_dust_change_is_left_to_the_fee():
"""B-06: `if change > 0` created change outputs below the dust limit, which makes
the whole transaction unrelayable the bet or withdrawal then failed at broadcast
with an opaque error the user could do nothing about."""
from app.wallet.psbt_builder import DUST_LIMIT_SATS
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
amount = 10_000_000
dust_change = DUST_LIMIT_SATS - 1
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo("33" * 32, 0, amount + dust_change)],
to_address=to_address,
amount_sats=amount,
change_address=change_address,
fee_rate_sat_vb=1,
)
tx = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(tx.vout) == 1 # no dust output
assert built.change_sats == 0
# Nothing vanishes: the dust ends up in the fee, and inputs still equal outputs+fee.
assert built.fee_sats >= dust_change
assert built.recipient_sats + built.change_sats + built.fee_sats == amount + dust_change
def test_change_at_the_dust_limit_is_still_paid_back():
from app.wallet.psbt_builder import DUST_LIMIT_SATS
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
amount = 10_000_000
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo("44" * 32, 0, amount + DUST_LIMIT_SATS)],
to_address=to_address,
amount_sats=amount,
change_address=change_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == DUST_LIMIT_SATS
assert len(Transaction.parse(bytes.fromhex(built.raw_hex)).vout) == 2
def test_dust_sized_recipient_amount_is_refused():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
change_address = script.p2wpkh(signer.to_public()).address(network=PLM_MAINNET)
with pytest.raises(InsufficientFundsError):
build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo("55" * 32, 0, 1_000_000)],
to_address=to_address,
amount_sats=400, # after the ~160 sat fee this lands under the dust limit
change_address=change_address,
fee_rate_sat_vb=1,
)
+343
View File
@@ -0,0 +1,343 @@
"""Regression tests for B-04 (and the "building" half of B-08): a transaction that
never made it onto the chain must give the coins back instead of freezing them.
Also covers B-41: existence/reconciliation checks go through
blockchain.scripthash.get_history rather than a verbose blockchain.transaction.get
reply, so the fake clients below implement get_history.
"""
import pytest
from embit import script
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal
from app.tx.reconcile import reconcile_once
class UnknownTxClient:
"""A server whose history for any address never includes our txid."""
async def get_history(self, scripthash: str) -> list[dict]:
return []
class KnownTxClient:
"""A server whose history for the address includes our txid — mined or
still in the mempool doesn't matter for existence, only for confirmation
(which is tx/confirmation.py's concern, not reconcile.py's)."""
def __init__(self, txid: str = "betxid"):
self._txid = txid
async def get_history(self, scripthash: str) -> list[dict]:
return [{"tx_hash": self._txid, "height": 100}]
class BrokenClient:
"""A transport failure — says nothing about whether the tx exists."""
async def get_history(self, scripthash: str) -> list[dict]:
raise ConnectionResetError("connection reset")
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
# own_address_for (B-41) derives each row's address via the HD wallet rather
# than trusting the DB's address column, so reconcile_once now needs a real
# master key set up — same bootstrap test_broadcast.py uses.
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
# A real (unsigned) transaction spending one input, built rather than hand-written
# so it round-trips through Transaction.parse — that parse is how the reconciler
# discovers which UTXOs to release, so a fixture the parser rejects would test
# nothing.
_TX_INPUT_TXID = "11" * 32
_RAW_TX = (
Transaction(
vin=[TransactionInput(bytes.fromhex(_TX_INPUT_TXID), 0)],
vout=[
TransactionOutput(
999_000_000, script.Script.from_address("plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd")
)
],
)
.serialize()
.hex()
)
async def _seed_bet(
session_factory,
*,
pending_status: str,
participant_status: str,
age_seconds: int,
last_broadcast_age_seconds: int | None = None,
derivation_index: int = 0,
):
from datetime import datetime, timedelta, timezone
from app.wallet.hd import derive_user_address
async with session_factory() as session:
user = User(
username="u",
password_hash="x",
derivation_index=derivation_index,
address=derive_user_address(derivation_index),
)
session.add(user)
await session.flush()
session.add(
UtxoEvent(
user_id=user.id,
txid=_TX_INPUT_TXID,
vout=0,
amount_sats=1_000_000_000,
confirmed_height=10,
spent_txid="betxid",
)
)
session.add(
RoundParticipant(
round_id=1,
user_id=user.id,
bet_amount_sats=999_000_000,
bet_txid="betxid",
status=participant_status,
)
)
# last_broadcast_age_seconds defaults to age_seconds (never bumped): the two
# timestamps only diverge in the B-27 regression test below, which simulates
# a tx that's been bumped recently but first appeared long ago.
last_age = age_seconds if last_broadcast_age_seconds is None else last_broadcast_age_seconds
session.add(
PendingTransaction(
kind="bet",
round_id=1,
user_id=user.id,
current_txid="betxid",
fee_rate_sat_vb=1,
raw_tx_hex=_RAW_TX,
status=pending_status,
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=age_seconds),
last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age),
)
)
await session.commit()
return user.id
async def test_abandons_a_building_bet_and_gives_the_coins_back(session_factory):
"""The crash-mid-broadcast case: the tx isn't on the chain, so the UTXO must be
released, the participant removed (they never entered the round) and the balance
restored. Before this existed, spent_txid stayed set forever and the user simply
lost the coins."""
user_id = await _seed_bet(
session_factory, pending_status="building", participant_status="building", age_seconds=300
)
resolved = await reconcile_once(session_factory, UnknownTxClient())
assert resolved == 1
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent))).one()
assert utxo.spent_txid is None # spendable again
assert (await session.scalars(select(RoundParticipant))).all() == []
row = (await session.scalars(select(PendingTransaction))).one()
assert row.status == "failed"
assert row.failure_reason
user = await session.get(User, user_id)
assert user.cached_balance_sats == 1_000_000_000
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "pending_tx_abandoned" in events
async def test_promotes_a_building_row_whose_tx_did_reach_the_chain(session_factory):
"""We died after the broadcast, not before: the tx is real, so the rows must be
finished rather than rolled back."""
await _seed_bet(
session_factory, pending_status="building", participant_status="building", age_seconds=300
)
resolved = await reconcile_once(session_factory, KnownTxClient("betxid"))
assert resolved == 1
async with session_factory() as session:
row = (await session.scalars(select(PendingTransaction))).one()
assert row.status == "pending"
participant = (await session.scalars(select(RoundParticipant))).one()
assert participant.status == "broadcast"
utxo = (await session.scalars(select(UtxoEvent))).one()
assert utxo.spent_txid == "betxid" # still legitimately spent
async def test_leaves_a_young_building_row_alone(session_factory):
"""A row written seconds ago may just be a broadcast still in flight."""
await _seed_bet(
session_factory, pending_status="building", participant_status="building", age_seconds=5
)
assert await reconcile_once(session_factory, UnknownTxClient()) == 0
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).one().status == "building"
async def test_leaves_a_recently_broadcast_pending_row_alone(session_factory):
"""A broadcast tx gets a wide grace window — absence from one server's mempool
is not proof of death, and the RBF bumper should get its attempts first."""
await _seed_bet(
session_factory, pending_status="pending", participant_status="broadcast", age_seconds=3600
)
assert await reconcile_once(session_factory, UnknownTxClient()) == 0
async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(session_factory):
"""B-27 regression: before last_broadcast_at existed, bump_fee overwrote
broadcast_at on every bump, which is the same field the abandon grace period is
measured from so a tx first seen long ago but bumped minutes ago (exactly what
a stuck-but-repeatedly-bumped tx looks like) reset its own clock forever and was
never abandoned. The reconciler must still abandon it based on when it *first*
appeared, ignoring how recently it was last bumped."""
await _seed_bet(
session_factory,
pending_status="pending",
participant_status="broadcast",
age_seconds=7 * 3600, # first broadcast 7h ago — past the 6h abandon window
last_broadcast_age_seconds=60, # bumped a minute ago
)
assert await reconcile_once(session_factory, UnknownTxClient()) == 1
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).one().status == "failed"
async def test_transport_failure_never_abandons_anything(session_factory):
"""A dead connection says nothing about the transaction. Treating it as "gone"
would release coins for transactions that are perfectly alive."""
await _seed_bet(
session_factory, pending_status="building", participant_status="building", age_seconds=300
)
assert await reconcile_once(session_factory, BrokenClient()) == 0
async with session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).one().status == "building"
assert (await session.scalars(select(UtxoEvent))).one().spent_txid == "betxid"
async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory):
"""Unlike a bet, a withdrawal is an instruction the user gave: the row stays so
they can see it didn't go through."""
from datetime import datetime, timedelta, timezone
from app.wallet.hd import derive_user_address
async with session_factory() as session:
user = User(username="w", password_hash="x", derivation_index=1, address=derive_user_address(1))
session.add(user)
await session.flush()
session.add(
UtxoEvent(
user_id=user.id,
txid=_TX_INPUT_TXID,
vout=0,
amount_sats=500_000_000,
confirmed_height=10,
spent_txid="wdtxid",
)
)
withdrawal = Withdrawal(
user_id=user.id,
external_address="plm1qexternal",
amount_requested_sats=400_000_000,
amount_sent_sats=399_000_000,
txid="wdtxid",
status="broadcast",
)
session.add(withdrawal)
await session.flush()
session.add(
PendingTransaction(
kind="withdrawal",
withdrawal_id=withdrawal.id,
user_id=user.id,
current_txid="wdtxid",
fee_rate_sat_vb=1,
raw_tx_hex=_RAW_TX,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(days=1),
)
)
await session.commit()
assert await reconcile_once(session_factory, UnknownTxClient()) == 1
async with session_factory() as session:
withdrawal = (await session.scalars(select(Withdrawal))).one()
assert withdrawal.status == "failed"
assert withdrawal.txid is None
assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None
# --- B-41: existence checks now use get_history and share it across candidates
# sharing the same address, instead of a per-tx verbose blockchain.transaction.get. --
async def test_reconcile_once_caches_history_per_scripthash(session_factory):
"""Two payout PendingTransaction rows always share the same pool address —
fetching its history twice in one pass would be wasteful and, at scale
across many candidates on one address, needlessly slow the whole tick."""
from datetime import datetime, timedelta, timezone
async with session_factory() as session:
old = datetime.now(timezone.utc) - timedelta(hours=7)
session.add(
PendingTransaction(
kind="payout", round_id=1, current_txid="payout-a", fee_rate_sat_vb=1,
raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old,
)
)
session.add(
PendingTransaction(
kind="payout", round_id=2, current_txid="payout-b", fee_rate_sat_vb=1,
raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old,
)
)
await session.commit()
call_count = {"n": 0}
class CountingClient:
async def get_history(self, scripthash: str) -> list[dict]:
call_count["n"] += 1
return [{"tx_hash": "payout-a", "height": 100}, {"tx_hash": "payout-b", "height": 100}]
resolved = await reconcile_once(session_factory, CountingClient())
assert resolved == 0 # both exist — nothing to abandon or promote (already "pending")
assert call_count["n"] == 1 # one call covered both rows sharing the pool address
+69 -1
View File
@@ -2,7 +2,7 @@ import asyncio
import pytest
from app.rounds.events import RoundEventBroadcaster, RoundEventCapacityError
from app.rounds.events import EVICTED, RoundEventBroadcaster, RoundEventCapacityError
async def test_publish_wakes_up_subscriber():
@@ -60,3 +60,71 @@ async def test_unsubscribe_frees_a_capacity_slot():
broadcaster.unsubscribe(queue)
broadcaster.subscribe() # no longer at capacity
# --- B-38: a single IP must not be able to exhaust the global cap and degrade
# every other user to polling. ----------------------------------------------------
async def test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap():
"""Past MAX_SUBSCRIBERS_PER_IP, one more stream from the *same* IP evicts
that IP's own oldest connection rather than being refused — bounds one
source's footprint without an outright block."""
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2)
first = broadcaster.subscribe("1.2.3.4")
second = broadcaster.subscribe("1.2.3.4")
third = broadcaster.subscribe("1.2.3.4") # past the per-IP cap of 2
assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED
assert second.empty() # untouched — only the oldest was evicted
assert third is not None
async def test_subscribe_does_not_evict_across_different_ips():
"""A different IP hitting its own cap must never evict an unrelated IP's
connection that would let one abusive source crowd out real users, which
is exactly what the global-only cap used to allow."""
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1)
other_ip_queue = broadcaster.subscribe("9.9.9.9")
broadcaster.subscribe("1.2.3.4")
broadcaster.subscribe("1.2.3.4") # evicts 1.2.3.4's own oldest, not 9.9.9.9's
assert other_ip_queue.empty()
async def test_subscribe_still_enforces_the_global_cap_across_many_ips():
"""The per-IP cap doesn't replace the global backstop — spreading across
enough distinct IPs must still eventually hit MAX_SUBSCRIBERS."""
broadcaster = RoundEventBroadcaster(max_subscribers=3, max_per_ip=1)
broadcaster.subscribe("1.1.1.1")
broadcaster.subscribe("2.2.2.2")
broadcaster.subscribe("3.3.3.3")
with pytest.raises(RoundEventCapacityError):
broadcaster.subscribe("4.4.4.4")
async def test_unsubscribe_clears_the_per_ip_tracking_too():
"""Regression guard: unsubscribe must forget the queue's IP association, or
a churned-through connection would keep counting against that IP's cap
forever."""
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1)
queue = broadcaster.subscribe("1.2.3.4")
broadcaster.unsubscribe(queue)
broadcaster.subscribe("1.2.3.4") # must not evict anything — nothing left to evict
assert queue.empty()
async def test_subscribe_defaults_to_a_shared_ip_when_none_given():
"""Existing callers (and most tests) that don't care about IP isolation
still share one implicit bucket rather than needing every call updated."""
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2)
first = broadcaster.subscribe()
broadcaster.subscribe()
broadcaster.subscribe() # past the default bucket's cap of 2 — evicts, doesn't raise
assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED
+98 -1
View File
@@ -42,7 +42,7 @@ async def client(monkeypatch, tmp_path):
app = FastAPI()
app.include_router(auth_router)
app.include_router(rounds_router)
app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -94,3 +94,100 @@ async def test_user_played_true_only_for_participants(client):
resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view
assert resp.status_code == 200
assert resp.json()["user_played"] is False
async def test_jackpot_comes_from_the_participants_actual_bets(client):
"""B-11: the jackpot was participant_count * the *current* bet_amount_sats, which
overstated it (each stored bet is already net of that bet's network fee) and
silently rewrote the advertised jackpot of a round in progress whenever an
operator edited the bet amount."""
from sqlalchemy import select
from app.db.models import Round, RoundConfig, RoundParticipant
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=50, status="open"))
await session.flush()
# Two bets that actually paid 999_800_000 each (fee deducted), not 1_000_000_000.
session.add(
RoundParticipant(round_id=50, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
)
session.add(
RoundParticipant(round_id=50, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="confirmed")
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["participant_count"] == 2
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
# Changing the configured bet amount must not move a running round's jackpot.
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.bet_amount_sats = 5_000_000_000
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
needs draw_waiting_since to show "still waiting" instead of implying a bounded
countdown. It must not leak for any other status, where it's meaningless."""
from datetime import datetime, timezone
from app.db.models import Round, RoundConfig
ac, session_factory = client
started_at = datetime(2026, 7, 27, 10, 0, 0)
async with session_factory() as session:
session.add(RoundConfig(fee_address=""))
session.add(Round(id=60, status="drawing", drawing_started_at=started_at))
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00"
async with session_factory() as session:
from sqlalchemy import select
round_ = (await session.scalars(select(Round).where(Round.id == 60))).one()
round_.status = "paying_out"
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["draw_waiting_since"] is None
async def test_unhandled_errors_use_the_structured_detail_shape(client):
"""B-24: the catch-all handler answered with a bare-string `detail`, while
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
had to special-case exactly the responses they understand least."""
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.main import log_unhandled_exception
app = FastAPI()
app.add_exception_handler(Exception, log_unhandled_exception)
@app.get("/boom")
async def boom():
raise RuntimeError("secret internal detail")
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/boom")
assert resp.status_code == 500
detail = resp.json()["detail"]
assert detail["code"] == "internal_error"
assert detail["message"] == "internal server error"
assert detail["params"] == {}
# The exception text belongs in logs/app.log, never in the response body.
assert "secret internal detail" not in resp.text
+66
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
@@ -105,3 +106,68 @@ async def test_pause_does_not_interrupt_a_round_in_progress(session_factory):
returned = await open_new_round_if_needed(session)
assert returned is not None
assert returned.status == "drawing"
async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch):
"""B-09: open_new_round_if_needed was a read-then-insert with no lock, called from
both the scheduler and every place_bet, so two callers could both see "no active
round" and insert one — and a second stuck "open" row blocks every future round,
since get_active_round matches on status.
The race is forced deterministically: the round already exists and is committed,
but this caller's first look is made to miss it (exactly what the loser of the
race sees). The insert then hits ix_rounds_single_active, and the caller must
recover by using the winner's round instead of raising at its caller — a bet must
not fail because a scheduler tick beat it by a millisecond.
"""
from app.rounds import service as service_module
async with session_factory() as session:
session.add(Round(status="open"))
await session.commit()
real_get_active_round = service_module.get_active_round
calls = {"n": 0}
async def blind_first_look(session):
calls["n"] += 1
if calls["n"] == 1:
return None # what the loser of the race sees
return await real_get_active_round(session)
monkeypatch.setattr(service_module, "get_active_round", blind_first_look)
async with session_factory() as session:
round_ = await service_module.open_new_round_if_needed(session)
await session.commit()
assert round_ is not None # recovered, didn't raise
async with session_factory() as session:
rounds = (await session.scalars(select(Round))).all()
assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}"
assert round_.id == rounds[0].id # the winner's round, not a second one
async def test_the_database_refuses_a_second_active_round(session_factory):
"""The guarantee itself, independent of the application code path."""
from sqlalchemy.exc import IntegrityError
async with session_factory() as session:
session.add(Round(status="open"))
await session.commit()
async with session_factory() as session:
session.add(Round(status="drawing"))
with pytest.raises(IntegrityError):
await session.commit()
async def test_closed_rounds_can_coexist_with_an_active_one(session_factory):
async with session_factory() as session:
session.add(Round(status="closed"))
session.add(Round(status="closed"))
session.add(Round(status="open"))
await session.commit()
async with session_factory() as session:
assert len((await session.scalars(select(Round))).all()) == 3
+407 -2
View File
@@ -4,9 +4,10 @@ import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import Round, RoundConfig
from app.rounds.scheduler import RoundScheduler
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User
from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints
class FakeListener:
@@ -52,3 +53,407 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory,
async with session_factory() as session:
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "closed"
# --- B-25: the payout must be persisted before it is broadcast, like bets/withdrawals ---
# A real, reusable PLM bech32 address so build_payout_transaction's
# script.Script.from_address(...) succeeds — this is not a value the scheduler
# validates itself (that's the admin panel's job for fee_address), it just needs to
# actually decode.
_WINNER_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
_POOL_AMOUNT_SATS = 10_000_000_000 # 100 PLM
class FakePayoutClient:
def __init__(self, entries, *, fail_broadcast=False):
self._entries = entries
self._fail_broadcast = fail_broadcast
self.broadcasted: list[str] = []
async def listunspent(self, scripthash):
return self._entries
async def broadcast(self, raw_tx_hex):
if self._fail_broadcast:
raise RuntimeError("node rejected the transaction")
self.broadcasted.append(raw_tx_hex)
return "network-txid"
class FakePayoutListener:
def __init__(self, client):
self.client = client
@pytest.fixture
async def payout_session_factory(tmp_path, monkeypatch):
"""Same master-key bootstrap as test_broadcast.py's fixture: _trigger_payout
needs a real pool key to sign with."""
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
async with session_factory() as session:
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
session.add(winner)
await session.flush()
session.add(RoundConfig(fee_address=_FEE_ADDRESS, fee_rate_sat_vb=1))
session.add(
Round(
id=round_id,
status="paying_out",
pool_amount_sats=_POOL_AMOUNT_SATS,
winner_user_id=winner.id,
)
)
await session.commit()
return winner.id
async def test_trigger_payout_persists_before_broadcasting(payout_session_factory):
"""The happy path: payout_txid and a PendingTransaction must exist once the
broadcast succeeds, promoted from "building" to "pending" the two-phase write
that used to be missing entirely (B-25)."""
await _seed_paying_out_round(payout_session_factory)
entries = [{"tx_hash": "33" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
assert client.broadcasted
async with payout_session_factory() as session:
round_ = await session.get(Round, 1)
assert round_.payout_txid is not None
assert round_.winner_amount_sats and round_.fee_amount_sats
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "payout"
assert pending.status == "pending"
assert pending.current_txid == round_.payout_txid
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "payout_sent" in events
async def test_trigger_payout_broadcast_failure_leaves_a_recoverable_row(payout_session_factory):
"""Before B-25, a broadcast rejection here left nothing behind — no payout_txid,
no PendingTransaction because everything was persisted only after the
broadcast. Now the intent is already durable, so the reconciler has something to
resolve instead of the round being stuck with zero trace of what was attempted."""
await _seed_paying_out_round(payout_session_factory)
entries = [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
client = FakePayoutClient(entries, fail_broadcast=True)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
assert not client.broadcasted
async with payout_session_factory() as session:
round_ = await session.get(Round, 1)
assert round_.payout_txid is not None # durable, even though the broadcast failed
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "payout"
assert pending.status == "building" # not lost — the reconciler resolves this
assert pending.current_txid == round_.payout_txid
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert "payout_failed" in events
async def test_trigger_payout_skips_when_already_in_flight(payout_session_factory):
"""A second call for a round that already has a non-terminal payout
PendingTransaction must not build (and broadcast) another one that would pay
the winner twice."""
winner_id = await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
round_ = await session.get(Round, 1)
round_.payout_txid = "already-sent-txid"
session.add(
PendingTransaction(
kind="payout",
round_id=1,
current_txid="already-sent-txid",
fee_rate_sat_vb=1,
raw_tx_hex="00",
status="pending",
)
)
await session.commit()
entries = [{"tx_hash": "55" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
assert not client.broadcasted
async with payout_session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).all() # still just the one seeded
rows = (await session.scalars(select(PendingTransaction))).all()
assert len(rows) == 1
assert rows[0].current_txid == "already-sent-txid"
async def test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout(payout_session_factory):
"""A payout still "building"/"pending" for some round — most plausibly a stale
one the reconciler hasn't abandoned yet — must keep its inputs off the table for
a fresh payout attempt, or the same pool coins could be spent twice."""
from embit import script
from embit.transaction import Transaction, TransactionInput, TransactionOutput
reserved_txid = "66" * 32
raw_tx = (
Transaction(
vin=[TransactionInput(bytes.fromhex(reserved_txid), 2)],
vout=[TransactionOutput(1_000_000, script.Script.from_address(_WINNER_ADDRESS))],
)
.serialize()
.hex()
)
async with payout_session_factory() as session:
session.add(
PendingTransaction(
kind="payout",
round_id=99,
current_txid="stale-payout-txid",
fee_rate_sat_vb=1,
raw_tx_hex=raw_tx,
status="building",
)
)
await session.commit()
reserved = await _reserved_payout_outpoints(session)
assert reserved == {(reserved_txid, 2)}
# --- B-26: a "paying_out" round must retry its payout automatically ---------------
async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_factory):
"""Before B-26, this early return logged nothing beyond a log line — invisible
in /admin and unusable as a signal for an automatic retry."""
await _seed_paying_out_round(payout_session_factory)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client=None))
await scheduler._trigger_payout(1)
async with payout_session_factory() as session:
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).all()
assert len(entries) == 1
assert entries[0].payload_json.count("electrum client not connected") == 1
async def test_trigger_payout_logs_a_failure_when_fee_address_missing(payout_session_factory):
winner_id = await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.fee_address = ""
await session.commit()
entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(FakePayoutClient(entries)))
await scheduler._trigger_payout(1)
async with payout_session_factory() as session:
entry = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).one()
assert "no fee_address configured" in entry.payload_json
assert entry.user_id == winner_id
async def test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure(payout_session_factory):
"""The scenario B-26 exists for: a round stuck in "paying_out" (a prior failure,
or a process restart mid-payout) with no non-terminal PendingTransaction. A
fresh tick must retry rather than leaving it wedged forever."""
await _seed_paying_out_round(payout_session_factory)
entries = [{"tx_hash": "88" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._tick()
assert client.broadcasted
async with payout_session_factory() as session:
round_ = await session.get(Round, 1)
assert round_.payout_txid is not None
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "pending"
async def test_tick_throttles_retry_after_a_recent_payout_failure(payout_session_factory):
"""A payout that just failed must not be retried on the very next tick, or a
persistently-broken payout (e.g. no fee_address) would spam a retry and a
fresh payout_failed audit entry every _TICK_INTERVAL_SECONDS."""
await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
session.add(
AuditLog(
event_type="payout_failed",
payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}',
round_id=1,
created_at=datetime.now(timezone.utc),
)
)
await session.commit()
entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._tick()
assert not client.broadcasted
async with payout_session_factory() as session:
assert (await session.scalars(select(PendingTransaction))).all() == []
async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory)
async with payout_session_factory() as session:
session.add(
AuditLog(
event_type="payout_failed",
payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}',
round_id=1,
created_at=datetime.now(timezone.utc) - timedelta(seconds=120),
)
)
await session.commit()
entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}]
client = FakePayoutClient(entries)
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._tick()
assert client.broadcasted
async with payout_session_factory() as session:
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "pending"
# --- B-28: the draw must not seed itself from an uncorroborated header -----------
class CorroboratingListener:
"""A fake listener whose tip advances the moment a corroboration attempt
fails, simulating a further block arriving lets tests drive
_wait_for_next_block's retry loop deterministically without real sleeps."""
def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None):
self.tip_height, self.tip_header_hex = next(iter(responses)), "aa"
self._responses = dict(responses)
self._advance_to = advance_to or {}
self.corroboration_calls: list[int] = []
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
self.corroboration_calls.append(height)
result = self._responses[height]
if not result and height in self._advance_to:
self.tip_height, self.tip_header_hex = self._advance_to[height]
return result
async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory):
listener = CorroboratingListener(responses={101: True})
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
)
assert height == 101
assert listener.corroboration_calls == [101]
async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory):
"""B-28: an uncorroborated header must never be used — the wait keeps going
until a later block's header *is* corroborated, logging why each time."""
listener = CorroboratingListener(
responses={101: False, 102: True}, advance_to={101: (102, "bb")}
)
scheduler = RoundScheduler(session_factory, listener)
height, block_hash = await scheduler._wait_for_next_block(
round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc)
)
assert height == 102
assert listener.corroboration_calls == [101, 102]
async with session_factory() as session:
events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()]
assert events == ["draw_header_corroboration_failed"]
# --- B-36: a stalled draw must be visible, not a silent frozen wait --------------
class StallingListener:
"""A tip that never advances until the test decides it should — used to drive
_wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS
without a real 6-minute wait."""
def __init__(self):
self.tip_height = 100
self.tip_header_hex = None
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
return True
async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch):
import app.rounds.scheduler as scheduler_module
listener = StallingListener()
scheduler = RoundScheduler(session_factory, listener)
start = datetime.now(timezone.utc)
class _FakeClock:
now = start
def fake_now(tz=None):
return _FakeClock.now
async def fake_sleep(seconds: float) -> None:
_FakeClock.now += timedelta(seconds=seconds)
# Past the stall threshold, but before it would repeat: unblock the wait
# by making a (corroborated) block appear, so the test terminates.
if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30):
listener.tip_height = 101
listener.tip_header_hex = "aa"
monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)}))
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start)
assert height == 101
async with session_factory() as session:
entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all()
assert len(entries) == 1
assert entries[0].round_id == 1
+33 -2
View File
@@ -9,5 +9,36 @@ def test_password_hash_roundtrip():
def test_jwt_roundtrip(monkeypatch):
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
token = security.create_access_token(user_id=42)
assert security.decode_access_token(token) == 42
token = security.create_access_token(user_id=42, token_version=3)
assert security.decode_access_token(token) == (42, 3)
def test_jwt_decode_defaults_token_version_for_tokens_issued_before_it_existed(monkeypatch):
"""B-34: a token minted before the "tv" claim existed has no such key at
all. It must still decode as token_version 0, matching a freshly
migrated user's starting value — rather than raising or being treated as
permanently stale."""
import jwt as pyjwt
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
payload = {"sub": "42"}
token = pyjwt.encode(payload, "test-secret", algorithm=security.settings.jwt_algorithm)
assert security.decode_access_token(token) == (42, 0)
def test_verify_password_returns_false_for_an_unparseable_hash():
"""B-13: only VerifyMismatchError was caught, so a corrupted stored hash raised
InvalidHashError and became an unhandled 500 on the login endpoint instead of a
plain "wrong credentials" 401."""
from app.auth.security import verify_password
assert verify_password("whatever", "not-an-argon2-hash") is False
assert verify_password("whatever", "") is False
def test_verify_password_still_rejects_a_wrong_password():
from app.auth.security import hash_password, verify_password
stored = hash_password("correct-horse-battery")
assert verify_password("correct-horse-battery", stored) is True
assert verify_password("wrong", stored) is False
+70 -2
View File
@@ -45,7 +45,7 @@ async def client(monkeypatch, tmp_path):
app = FastAPI()
app.include_router(auth_router)
app.include_router(users_router)
app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
@@ -81,7 +81,8 @@ async def test_change_password_updates_login(client):
headers=headers,
json={"current_password": "original-password", "new_password": "brand-new-password"},
)
assert resp.status_code == 204
assert resp.status_code == 200
assert resp.json()["access_token"]
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 401
@@ -90,6 +91,36 @@ async def test_change_password_updates_login(client):
assert resp.status_code == 200
async def test_change_password_invalidates_the_old_token_but_not_the_new_one(client):
"""B-34: neither self-service change-password nor the admin reset used to
invalidate already-issued JWTs, so a stolen token (or an attacker who
already had the old password) stayed logged in until the token's natural
24h expiry even past a password change meant to lock them out."""
old_token = await _register(client)
old_headers = {"Authorization": f"Bearer {old_token}"}
resp = await client.post(
"/users/me/change-password",
headers=old_headers,
json={"current_password": "original-password", "new_password": "brand-new-password"},
)
assert resp.status_code == 200
new_token = resp.json()["access_token"]
assert new_token != old_token
# The old token (what an attacker holding the old password would still
# have) is now rejected...
resp = await client.get("/users/me", headers=old_headers)
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "session_expired"
# ...but the freshly issued one keeps this same session working, so the
# user who just changed their own password isn't logged out too.
new_headers = {"Authorization": f"Bearer {new_token}"}
resp = await client.get("/users/me", headers=new_headers)
assert resp.status_code == 200
async def test_change_password_rejects_too_short(client):
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
@@ -108,3 +139,40 @@ async def test_change_password_requires_auth(client):
json={"current_password": "x", "new_password": "brand-new-password"},
)
assert resp.status_code in (401, 403)
@pytest.mark.parametrize(
"payload",
[
{"username": "", "password": "longenough1"},
{"username": "ab", "password": "longenough1"}, # under 3 chars
{"username": "bad user!", "password": "longenough1"}, # disallowed characters
{"username": "validname", "password": "short"}, # under MIN_PASSWORD_LENGTH
{"username": "validname", "password": ""},
],
)
async def test_register_rejects_weak_credentials(client, payload):
"""B-12: registration accepted an empty username and a one-character password,
while /users/me/change-password demanded 8 an odd place to be lenient on a
custodial system holding real funds."""
resp = await client.post("/auth/register", json=payload)
assert resp.status_code == 422
async def test_register_accepts_valid_credentials(client):
resp = await client.post("/auth/register", json={"username": "goodname", "password": "longenough1"})
assert resp.status_code == 201
async def test_me_created_at_is_utc_stamped(client):
"""B-35: SQLite/aiosqlite returns DateTime columns as naive, even though every
value written is UTC (app.db.models.utcnow). A bare .isoformat() on that naive
value has no "Z"/offset, and JavaScript's `new Date()` then parses it as local
time instead of UTC."""
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get("/users/me", headers=headers)
assert resp.status_code == 200
created_at = resp.json()["created_at"]
assert created_at.endswith("+00:00") or created_at.endswith("Z")
+98 -1
View File
@@ -2,9 +2,11 @@ import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User, UtxoEvent
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
@@ -99,6 +101,35 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
async def test_withdrawal_distinguishes_pending_from_truly_insufficient_balance(session_factory):
"""B-37: right after a bet, cached_balance_sats is ~0 because the whole funding
UTXO was spent as input and the change hasn't confirmed yet — but the UI shows
the pending-inclusive balance (compute_pending_balance), which does cover a
withdrawal of this size. The error must say "not confirmed yet", not flatly
"insufficient balance", or it contradicts what the user is looking at."""
user_id = await _make_funded_user(session_factory, 4, 3_000_000_000)
bet_client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, bet_client, user)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0 # the whole funding UTXO was spent as input
withdraw_client = FakeElectrumClient()
with pytest.raises(WithdrawalError) as exc_info:
# Above the withdrawal minimum (BET_AMOUNT_SATS) and covered by the
# unconfirmed change (~1_999_800_000 sats), but not by the (zero)
# confirmed balance.
await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, 1_500_000_000)
assert exc_info.value.code == "balance_pending_confirmation"
assert exc_info.value.params["pending_sats"] > 0
assert not withdraw_client.broadcasted
@pytest.mark.parametrize(
"address",
[
@@ -122,3 +153,69 @@ async def test_withdrawal_rejects_non_plm_address(session_factory, address):
assert exc_info.value.code == "invalid_address"
assert not client.broadcasted
async def test_withdrawal_to_own_address_is_rejected(session_factory):
"""B-17: allowed before, and it broke two things that assume the recipient and
the change are distinguishable by address the RBF bump would shrink the
recipient output, and compute_pending_balance counted the amount twice."""
user_id = await _make_funded_user(session_factory, 8, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="own deposit address"):
await request_withdrawal(session, client, user, user.address, 1_000_000_000)
assert not client.broadcasted
async with session_factory() as session:
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The released UTXOs are spendable again and the balance changed back, so the
rollback must nudge the dashboard to refetch instead of leaving it stale until
its next poll."""
user_id = await _make_funded_user(session_factory, 10, 3_000_000_000)
class RejectingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="refused"):
await request_withdrawal(session, RejectingClient(), user, derive_user_address(98), 1_000_000_000)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(session_factory):
"""B-07/B-08: the Withdrawal row is kept (unlike a bet) so the user can see the
instruction didn't go through, but the coins must come back."""
user_id = await _make_funded_user(session_factory, 9, 3_000_000_000)
class RejectingClient:
async def broadcast(self, raw_tx_hex: str) -> str:
raise RuntimeError("min relay fee not met")
async with session_factory() as session:
user = await session.get(User, user_id)
external = derive_user_address(99)
with pytest.raises(WithdrawalError, match="refused"):
await request_withdrawal(session, RejectingClient(), user, external, 1_000_000_000)
async with session_factory() as session:
withdrawal = (await session.scalars(select(Withdrawal))).one()
assert withdrawal.status == "failed"
assert withdrawal.txid is None
assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None
user = await session.get(User, user_id)
assert user.cached_balance_sats == 3_000_000_000