ElectrumListener._run_once assigned self.client before subscribe_headers()
returned, so there was a window — one round-trip wide, at process start — where
the connection looked alive while tip_height was still its initial 0.
"client is not None" is what every consumer reads as "the chain is reachable",
RoundScheduler._tick included, and a round closing inside that window recorded
tip_at_close = 0. The very first header we then learned about — the current tip,
a block mined *before* the round closed, whose hash was already public while
bets were still open — satisfied tip_height > tip_at_close and became the draw's
entropy. The draw's whole guarantee is that its seed did not exist yet when
betting stopped.
Two changes, defending different things:
- The client is published only once the first header has been applied, so
"client is not None" now means "reachable *and* we know where the chain is".
During the window consumers see no connection, which is honest: a bet gets the
same 503 it already gets while disconnected, and the background tasks skip a
cycle as they already do.
- _wait_for_next_block treats a baseline of 0 as *unknown*, not as height zero:
it adopts the first height it learns as the baseline, waits for a block
strictly after it, and records draw_baseline_tip_unknown so the extra block of
waiting is explainable from /admin. Unreachable via the listener now, but it is
the local statement of what the draw requires, and nothing else in that
function would notice if the invariant stopped holding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The max-amount checkbox sends amount_sats == the whole confirmed balance, so
change came out at 0, the change output was dropped, and the transaction had a
single output. bump_fee has nothing to shrink there: it raised RbfError every
30s until the reconciler abandoned the row six hours later. The RBF
single-change-output limitation was a documented gap, but the UI made it the
*default* withdrawal path.
The extra-input fallback would not have helped this case: a transaction moving
the entire balance already spends every UTXO the sender has. So the fix is at
build time — build_signed_transaction never produces a change output below
DUST_LIMIT_SATS, and never folds it into the fee either:
- withdrawals pass reduce_amount_to_keep_change=True and move a dust limit less.
The fee already comes out of the withdrawn amount by design, so this is the
same rule applied a little harder, and Withdrawal.amount_requested_sats vs
amount_sent_sats already existed to record the difference.
- bets don't: the bet is a fixed price that can't be quietly reduced. A balance
exactly equal to the bet is refused with balance_leaves_no_change (translated
into all 7 languages, carrying required_extra_sats), which turns "a user's
balance must never exactly equal the bet" from a documented assumption into an
enforced one — and stops an unbumpable bet from holding a round open until the
reconciler gives up on it.
bump_fee's no-change guard stays: a single-output tx broadcast before this
change can still be pending across the deploy, and it must fail loudly rather
than start shrinking a recipient's output. Its test now hand-builds that shape,
precisely because the builder no longer will.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
round_duration_seconds was read live on every scheduler tick and every bet
check, with the deadline computed as opened_at + duration. Lowering it from 600
to 60 while a round was 300s in closed that round instantly; raising it moved
the closes_at clients were already counting down to. round_cooldown_seconds had
the same property for the gap after a close. B-11 fixed this class of problem
for the advertised jackpot; the timing fields were left live.
Round now carries duration_seconds and cooldown_seconds, set from the config
when it opens. round_deadline() is the single place the deadline is computed —
the scheduler, place_bet's two checks and /rounds/current's closes_at all go
through it — and the cooldown is read off the round that just closed, so the gap
a round announced is the gap that's honoured. The config row becomes what the
*next* round opens with.
The migration backfills from the live config rather than leaving the column
defaults: an instance running 300s rounds would otherwise see the round
currently in progress jump to 600s the moment this lands, which is precisely the
retroactive change being fixed. Verified against a scratch DB with a non-default
config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other admin mutation — config edit, pause/resume, privkey export, password
reset — leaves a trace; this one could silently mark a report resolved. With one
shared ADMIN_TOKEN and no per-admin identity, the audit log is the only
accountability there is.
bug_report_status_changed records the report id and the before/after status, and
carries the report's author as user_id so the entry is traceable from either
side. Nothing is written when the status doesn't actually change, matching
config_updated: an edit that changes nothing isn't an event, and noise hides the
real changes.
Also documents the new event in docs/guida-admin.md's audit table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A candidate external spend has needed a quorum since B-29, but `value` and
`height` for a *credit* came from the single active connection and went straight
into utxo_events. One hostile or broken server could therefore inflate a user's
displayed balance with outpoints that don't exist. It never spends anyone else's
coins — a bet or withdrawal built on a phantom UTXO is refused at broadcast and
rolled back — but it wedges the balance display and burns build attempts, and on
a custodial platform a balance that isn't real is a support incident either way.
Balances move in both directions; both directions now need the same quorum.
corroborate_utxo_credit asks the other configured servers whether they report
the same outpoint, for the same amount, confirmed. The height itself isn't
compared: a server still catching up reports height 0 and simply doesn't agree,
which is the same answer, while two honest servers can't disagree on the height
of a genuinely confirmed outpoint.
refresh_user gains the phase that shape already implied: find_new_credit_
candidates (new, confirmed, not already held) inside the first session,
corroboration outside any session, then credit_confirmed_utxos over what
survived. Only new outpoints are corroborated — re-checking what we already hold
would open a connection to every other server on every refresh for an answer
that can no longer change anything.
A failed corroboration delays a credit, it never loses one: the next scripthash
notification or DepositReconciler sweep (300s) re-offers the same outpoint, and
the withholding is logged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registration throttle called record_failure on every attempt, successful
ones included. Five legitimate signups from one shared or NAT address locked the
sixth real user out for up to 600s, doubling from there — while an attacker
sidestepped the limiter entirely through B-54. Failure backoff is the wrong
instrument here: nothing about creating an account is a failed guess at a
secret, so the only people it reliably punished were the honest ones.
RollingQuota says what was actually meant: 5 accounts per IP per hour, in a
rolling window. The caller over it waits exactly until the oldest of the five
ages out — an accurate Retry-After, and waiting never makes the next wait
longer. It is recorded only once an account exists, so attempts that create
nothing (a taken username, a validation error) leave the quota untouched, and
checked before the Argon2 hash, so an IP out of quota costs nothing to refuse.
Bounded like the failure limiter (B-56): the keys are caller-chosen, so the dict
gets both a sweep and a hard cap, evicting keys with room left in their quota
before full ones.
Also fixes the inline comment that cited B-31 (the resubscribe finding) where it
meant B-33.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The login throttle keyed on body.username.lower() while the lookup matched
User.username exactly, so "Bob" and "bob" were two accounts sharing one
rate-limit bucket — each able to lock the other out — and registration happily
accepted near-duplicate names, which on a custodial system is an impersonation
vector.
Uniqueness is now the database's job: a unique index on lower(username), with
register and login both matching through func.lower(). The name is still stored
exactly as typed, since that's what /admin and the audit log display, and the
username pattern is ASCII-only so lower() is the whole of the normalization.
The migration refuses to run if two existing accounts differ only by case. It
can't merge or rename one automatically: both are custodial accounts that may
hold funds, so that would be the migration silently deciding who owns what. It
names the collisions and leaves them to the operator — the container runs
`alembic upgrade head` at startup, so it surfaces as a refusal to start rather
than a half-applied schema. Verified both directions against a scratch DB, plus
`alembic check` (clean) and the collision guard actually firing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_buckets is keyed by strings the caller chooses — any username, and (before
B-54) any IP — and only ever grew: decay_seconds aged a bucket's counter but
never removed the entry, so hammering login with random usernames was an
unbounded memory leak.
A bucket is "spent" once its lockout has expired *and* its failure count would
decay to zero on the next failure anyway — at which point keeping it and
dropping it are indistinguishable, which is what makes eviction safe. Those are
swept on record_failure (at most once every 60s) and on the read path, so a key
that's merely being probed never leaves an entry behind. That alone holds the
dict at the size of the genuinely active attack surface.
_MAX_BUCKETS = 50_000 is the backstop for a burst faster than the sweep
interval, when nothing has had time to expire. Over it, the entries closest to
expiry go first: what an attacker gets from a successful flood is the loss of
the shallowest, nearly-over lockouts, never the deep ones actually holding an
attack back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Argon2 is deliberately expensive — tens of milliseconds of CPU per call. Called
inline from the async handlers for register, login, change-password and the
admin reset, that cost froze the entire process for its duration: every other
request, plus all six background tasks (scheduler, confirmation poller, RBF
bumper, listener, both reconcilers). A burst of unauthenticated login attempts
was therefore not just slow logins, it delayed draws and confirmations.
hash_password_async/verify_password_async wrap the existing pair in
run_in_threadpool, and every async caller now uses them. The synchronous
functions stay: they're what the wrappers call, and what tests and scripts (no
running loop) use directly.
The regression test runs a heartbeat task alongside the hashing and counts how
often the loop got to run it — 1 tick with the old inline call, many with the
threadpooled one.
Also drops the running "already fixed and removed" list from BUGS.md: the file
tracks open findings, and `git log --all --grep 'B-nn'` is the record of how a
closed one was closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
client_ip() read the first element of X-Forwarded-For, which is correct only if
the proxy replaces the header. Caddy appends the peer address to whatever the
client sent, so element 0 was whatever the caller claimed: rotating a fake value
per request minted a fresh identity every time and walked straight through the
login and registration throttles (B-33) and the SSE per-IP subscriber cap
(B-38). Only the per-username login bucket, which doesn't key on the IP, still
bit.
Both halves of the audit's fix, since they hold independently:
- the Caddyfile overwrites the header with `header_up X-Forwarded-For
{remote_host}`, so what reaches the app is the actual peer and nothing else.
This is the one that makes the app's assumption true at the source.
- client_ip() reads the *last* hop rather than the first — the element written
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
sits in front of the app (`app` is only `expose`d on the compose network,
never published to the host), so that element is the real peer.
An empty or comma-only header now falls back to request.client.host instead of
returning "", which was its own shared-bucket evasion.
Regression tests both sides: two requests spoofing different prefixes must key
to the same IP, and the Caddyfile must keep the header_up directive (checked by
`caddy validate`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
place_bet commits its participant row as "building" before broadcasting (B-08's
two-phase write), while the scheduler flips the round "open" -> "closing" in one
transaction and counts in-flight participants in another. A bet whose deadline
check passed just before that flip could commit in between: the count saw zero,
so the round drew and paid out over the "confirmed" participants only, while the
bet confirmed normally and its sats landed in the pool address — credited to no
round, to no participant, with no refund path, silently improving the next
round's payout change.
Two locks on the same door:
- place_bet re-checks the deadline after building and signing (the first check
happens before the UTXO scan, so a slow build could carry a bet past it), then
commits the participant row behind a compare-and-set on the round's own row,
UPDATE rounds ... WHERE status = 'open'. That UPDATE takes SQLite's write lock,
so the two transactions can no longer interleave: either the bet commits first
and the scheduler's in-flight count sees it, or the flip commits first and the
guard matches zero rows and refuses the bet with round_closing before anything
is broadcast. A write-snapshot conflict (OperationalError) is the same
situation and gets the same answer. Nothing has been broadcast at that point,
so the rollback releases the UTXOs and leaves no rows behind.
- _close_and_draw re-counts in-flight bets in the same session it snapshots the
participants from, and returns with the round still "closing" if it finds any.
Redundant given the CAS, and cheap: it fails safe and the next tick retries.
No new error code — a bet refused this way is exactly the "round is closing"
case the user already sees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The payout has to spend one pool UTXO per bet, so reusing MAX_TX_INPUTS (50)
for it made any round past ~50 players unpayable: select_utxos raised
too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and
since no new round may open while one is active, the whole lottery stopped with
the pool stuck. The cap was being enforced on the payout side, i.e. discovered
once the money was already committed and there was no way back.
Two halves:
- select_utxos takes the cap as a parameter. Bets and withdrawals keep
MAX_TX_INPUTS = 50, which protects a user from a fee that eats into the amount
they are moving; the payout uses MAX_PAYOUT_TX_INPUTS = 500, where that
argument doesn't apply — 400 inputs at 1 sat/vB cost ~0.00027 PLM out of the
winner's 70% share. What actually bounds it is relay policy: 500 inputs is
~34 kvB against the 100 kvB standardness limit, and signing that many measures
~0.4s, once per round, inside a background task.
- place_bet refuses the 401st bet with a new round_full error (translated into
all 7 languages), so "a round can always be paid out" is an invariant checked
before any money moves. MAX_PARTICIPANTS_PER_ROUND sits below the input cap to
leave the payout headroom for pool change from earlier rounds, and counts every
participant row rather than only confirmed ones, since a failed bet frees a slot.
A round already wedged past the old cap now pays out on the next retry tick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third full-codebase audit, after the two earlier lists (B-01 … B-24 and
B-25 … B-49) were emptied and BUGS.md deleted. Analysis only — nothing is
fixed yet; each entry gets its own commit with its own regression test.
Branched off feature/bug-reports rather than main on purpose: the audit
describes this tree, /report-bug included (see B-68).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
_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.
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.
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>
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>
/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>
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>
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>
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>
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.
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.
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.
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.
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>
_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>
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>
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>
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>
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.
_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.
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.
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.
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.
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.
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>
_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>
_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>
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>
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>
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>
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>