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>
Design pass on the bug report page, staying inside the site's existing
design system (tokens, IBM Plex Sans, card/badge/pill components, stroke
icon set) rather than introducing a new one:
- A slim top bar (brand mark + back-to-home pill button + language switcher)
replaces the bare floating heading, so the page reads as part of the
product instead of an orphaned form.
- The "write in English" notice moves inside the form card, right above the
field it applies to, and switches from the amber "needs attention" tone to
an accent-tinted info tone, so it doesn't visually collide with the
bug-status badges' own use of amber for "not read yet".
- "Your reports" is promoted to a proper labeled section with a cleaner row
layout (truncated description with a title tooltip, compact date).
- A character counter on the description field.
- The back-to-home control is now a bordered pill with an arrow icon instead
of a bare text link with a hardcoded "←", which also meant dropping that
hardcoded arrow from all 7 translations.
Also, two content refinements based on feedback:
- Max description length dropped from 5000 to 2000 characters, enforced on
both the textarea and the API's Pydantic validator.
- The "read" status is relabeled from a passive "read"/"letta" to an active
"acknowledged"/"presa in carico" (and each other language's own equivalent
helpdesk term) — it communicates a team is on it, not just that someone
glanced at it. Only the label changed; the underlying "read" status value
in the API/DB is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The bug report form previously shipped as plain Italian only. It now shares
i18n.js with / (same TRANSLATIONS table, new bugReport.* keys in all 7
languages, own language switcher since the page has no navbar to hang one
off), so a non-Italian speaker can read the form and their own report
history in their language.
The description field itself still has to reach the admin panel in English
(operator-facing, untranslated by design), so the page states that
explicitly via a standing banner (bugReport.englishNotice) — translated
into every language rather than left in English, so the instruction to
write in English is itself understandable to whoever's reading it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Turns the /report-bug placeholder into a real form (POST /bug-reports,
optionally attributed to the logged-in user) and adds a "Segnalazioni bug"
section to /admin to view and triage them. A logged-in reporter can also
check their own report's status via GET /bug-reports/mine, since anonymous
submissions have no user to show a history to.
Status is a three-state lifecycle (open -> read -> resolved) rather than a
plain boolean, so an admin can acknowledge a report distinctly from actually
fixing it. The schema went through two migrations because the first one
(add bug_reports table) had already been applied against the running
instance with a `resolved` boolean before the three-state design was
decided, so a follow-up migration backfills it into `status` instead of
rewriting already-applied history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pool_amount_sats * 70 // 100 was hardcoded identically in both
rounds/scheduler.py (the actual payout) and api/routes/rounds.py (the
advertised jackpot). They happened to agree, but nothing enforced it —
changing one without the other would have made GET /rounds/current's
jackpot silently diverge from the real payout. Extract winner_share()
into rounds/service.py as the single source of truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A change output's confirmation is credited by two independent, unordered
paths: the Electrum listener (event-driven, near-instant — credits it as
a UtxoEvent and folds it into cached_balance_sats via recompute_balance)
and this module's PendingTransaction.status flip (tx/confirmation.py,
polled every 10s). The listener normally wins that race, so for the gap
until the poller catches up, compute_pending_balance kept adding the same
change on top of a cached_balance_sats that already included it —
observed live as a user's displayed balance briefly jumping by exactly
the change amount before self-correcting a few seconds later.
Fix: skip any change output whose (txid, vout) already has a UtxoEvent
for this user before summing pending_change_sats.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
winner_user_id is committed as soon as the draw picks a winner, but
winner_amount_sats isn't set until the payout tx is built afterwards
(a real Electrum listunspent round-trip later, in a separate DB
transaction). The frontend revealed the win banner as soon as
winner_user_id appeared, formatPlm(undefined) rendered as "—", and
the toast/result box briefly showed "You won! +— PLM" until the next
poll picked up the real amount. Gate the winner's own reveal on
winner_amount_sats also being non-null; a loss can still reveal
immediately since it never needs the amount.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
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>
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>
/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>
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.
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>
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>
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>
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>