Commit Graph
100 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 9207bbcb8f Don't reveal the win banner before winner_amount_sats is known (B-50)
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>
2026-07-31 15:03:42 +02:00
davide 5cfe2d6f95 Merge audit-2026-07-27: fix all 25 findings of the second audit
B-25 … B-49, each in its own commit with its own regression test — the suite
went from 139 to 253 tests. Also on this branch: Docker + Caddy security
headers, the API docs gate, admin endpoint limits, and the SSE gaps.
2026-07-27 23:37:12 +02:00
davide 0d2fef6502 Delete BUGS.md now that both audits' findings are closed
B-01 … B-49 are all fixed, so the file held no open work — only a history that
git already keeps. CLAUDE.md now explains how to resolve the B-nn markers left
throughout the code against that history, and repeats the caveat the empty list
does not carry on its own: no open findings is not the same as no bugs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The four critical ones:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:10:50 +02:00
davideandClaude Opus 5 d4e0974881 Let JS own the live status labels instead of sharing them with data-i18n
#chain-status-label and #draw-label carried a data-i18n attribute *and* were
written from live state by app.js, so a language switch had both mechanisms
fighting over them: applyStaticTranslations reset each to its static default
and the next poll put the real value back. On the draw label that was a
flicker. On the status bar it was a false statement — with the connection
down, the bar went back to claiming "connecting" until a further fetch failed,
up to a full poll interval later.

The attribute is gone from both. The status bar is now rendered from
remembered state (last payload, plus whether we're in the offline state)
rather than straight from the response that triggered it, so a language switch
repaints it correctly and immediately, with no fetch involved.

logout() also stops wiping the chosen language: localStorage.clear() took
plm_lang with it, dropping the user back to the browser-detected default on
the one screen where they'd have to go find the switcher again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:55:37 +02:00
davideandClaude Opus 5 28c1179e9b Recover from expired sessions and malformed requests in the dashboard
Three failure paths that ended at an English HTTP status line or at no
recovery at all:

An empty or non-numeric withdrawal amount parsed to NaN, which JSON.stringify
sends as null, which pydantic rejects with a 422 — and FastAPI's validation
errors use a list of field objects rather than the {code, message} shape, so
apiErrorMessage fell through to res.statusText and the user read
"Unprocessable Content". The amount is now checked before the request, and the
list shape maps to a translated "invalid request" as a backstop for any other
field that fails validation.

A token the server no longer accepts left the dashboard looking logged in
while every poll failed, re-toasting "session expired" indefinitely. call()
now logs out on that specific code, dropping back to the login form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:54:56 +02:00
davideandClaude Opus 5 5c9ccc0344 Reject withdrawal addresses that aren't PLM
embit's Script.from_address accepts a well-formed bech32 address from any
chain: a Bitcoin bc1... parses into a perfectly valid witness program. So a
withdrawal to a BTC address built, signed and broadcast normally on PLM, and
the funds landed on a script nobody holds the key for — silently, with no
error anywhere. A malformed address fared slightly better only in that it
crashed the request with an unhandled 500.

is_valid_plm_address checks the HRP as well as the parse, and runs first in
request_withdrawal, before a single UTXO is touched. It matches what the
withdrawal form already told the user (bech32 plm1q... only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:45:07 +02:00
davideandClaude Opus 5 0cf35147ad Answer user-facing API failures with a machine-readable error code
The dashboard now speaks seven languages but every failure path still showed
the API's raw English text ("insufficient balance", "current password is
incorrect"), which is the most frequent and least forgiving part of the UI to
leave untranslated.

Rather than teach the API about locales, it keeps answering in one language
and hands the client something to translate: `detail` becomes
{code, message, params}, where message stays English for non-dashboard
consumers (curl, tests) and code maps onto `error.<code>` in i18n.js. An
unknown code falls back to message, so a client older or newer than the server
degrades to English instead of a blank toast.

Domain exceptions (BetError, WithdrawalError) subclass the new ApiError and
carry the code from where the failure actually happens; str(exc) is still the
English message, so existing tests keep matching on it. Interpolated values
travel in params rather than baked into the English sentence — amounts as
*_sats, from which the frontend derives a *_plm sibling, so each language can
place them wherever its grammar wants.

admin.js reads detail.message defensively: the admin endpoints still return a
bare string, but the shared auth dependencies now return the structured form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:44:39 +02:00
davideandClaude Opus 5 7048fe7ea6 Translate the user-facing dashboard into 7 languages
Adds app/static/i18n.js: a flat key -> string table for en/it/es/fr/de/ru/zh,
loaded before app.js so t() is available everywhere. No build step and no
fetch, consistent with the rest of these static pages. Language comes from
localStorage, then navigator.language, then en.

Static markup is translated by attribute (data-i18n and its -html/-placeholder/
-title/-aria-label/-alt variants); anything rendered from server data goes
through t() in app.js and is re-rendered by onLanguageChange(). An element
belongs to one mechanism or the other, never both, or the two overwrite each
other — which is why #bet-btn has no data-i18n: its label carries the
admin-configurable bet amount, so renderBetButton() owns it and reads the
amount from /rounds/current instead of hardcoding "10 PLM" in seven files.

The switcher sits in the chain-bar rather than the navbar because the navbar
is hidden until login, which would leave the landing page and the login form
untranslatable for exactly the users who need to switch. It uses language
names rather than flag emoji: flags don't render on every platform and don't
map one-to-one onto languages.

withLoading now snapshots innerHTML instead of textContent — several of these
buttons wrap an <svg> plus a <span data-i18n>, both of which a textContent
round-trip flattened away, permanently losing the icon and the translation
hook. It re-applies translations to the restored subtree in case the language
changed while the request was in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:43:35 +02:00
davide 8a3dfd4592 Replace flowchart.mmd with per-topic diagrams and a print pipeline
Split the single flowchart.mmd into flowchart/platform-overview.mmd
(all 5 phases) and flowchart/round-lifecycle.mmd (round/draw detail),
plus render-pdf.sh to generate print-ready A4/A3 PDFs with a consistent
theme, header/footer, and legible contrast against the page background.

Also flip the operational policy in CLAUDE.md: the app now always runs
via Docker (dev and prod alike), with the venv reserved for tests,
migration authoring, and one-time secret/key-generation scripts.
2026-07-23 16:24:28 +02:00
davideandClaude Sonnet 5 bae08f2759 Extract inline JS from index.html/admin.html into app.js/admin.js
Mirrors the earlier CSS extraction (style.css/admin.css) — app/static/ is
now split cleanly by file type (markup, styles, script) instead of mixing
JS inline in the HTML. No behavior change: the script content moved
verbatim, referenced via <script src>. FastAPI's existing StaticFiles mount
serves the new files automatically, same as the CSS files already do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:05:38 +02:00
davideandClaude Sonnet 5 aae0961c94 Bring docs in sync with recent features (pending balance, SSE, per-player reveal)
CLAUDE.md: bumped the stale test count (54 -> 76), added "Balance display"
and "Real-time updates (SSE)" sections, and rewrote the DRAW section's
frontend-reveal paragraph to describe the actual current behavior (dual
status/result boxes gated by user_played, closes_at-anchored reveal delay,
localStorage persistence, the last-round-result backstop) instead of the
older single-box design. Refined the "no history endpoints" known gap now
that GET /users/me/last-round-result exists (still not general history).

README.md: same test count fix, expanded coverage list.

docs/: fixed a pre-existing broken link in setup.md (admin-guide.md ->
guida-admin.md), added a note in running-the-server.md that editing the
bind-mounted Caddyfile needs an explicit `docker compose restart caddy`
(discovered while adding the SSE Caddy config in a prior change), and
rewrote guida-utente.md's draw/reveal section plus the balance/withdrawal
sections to match what the UI actually does now. guida-admin.md was
reviewed but needed no changes.

app/static/style.css: dropped `.toast.info`, dead since the toast-based
loss notification it styled was replaced by the persistent result box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:05:27 +02:00
davideandClaude Sonnet 5 dda5bd14e1 Wire up the SSE push channel in both frontend dashboards
app/static/index.html: opens an EventSource against /rounds/stream (no auth
needed, see the previous commit) alongside the existing polling loops. On an
"update" notification, immediately re-runs the same refreshes polling would
eventually do (refreshRound/refreshMe/checkLastRoundResult when logged in,
refreshChainStatusOnly when logged out). Also reacts to the browser's "open"
event, which fires on the initial connection and on every automatic
reconnect — this re-syncs right away instead of leaving the page on stale
state until the next event or poll tick, which matters most right after a
dropped connection comes back.

app/static/admin.html: same channel, refreshing the chain-status bar and
whichever admin section is currently open (Utenti/Round/Transazioni
pendenti/Audit log) instead of requiring a manual tab switch to see new data.

Polling intervals are untouched in both pages — this is purely additive, so
a blocked/dropped SSE connection just degrades to the pre-existing behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:52:50 +02:00
davideandClaude Sonnet 5 f229f91632 Add server-push (SSE) notifications for round/bet/balance state changes
Frontend dashboards previously found out about state changes only on their
next poll tick (up to 15s, or 3s during a draw) — this adds a push channel
so updates land as soon as they happen instead.

- app/rounds/events.py: a small in-process pub/sub (RoundEventBroadcaster).
  The message carries no payload — it's just a "something changed, go
  refetch" signal, so it needs no auth and no knowledge of who's allowed to
  see what; personalization stays entirely in the existing REST endpoints.
- GET /rounds/stream: an SSE endpoint exposing that channel, with keep-alive
  comments so it survives idle periods behind a reverse proxy, and a
  defensive MAX_SUBSCRIBERS cap (well above the ~100 concurrent users
  expected) — past it, the endpoint returns 503 instead of opening a stream,
  and callers just keep working off polling.
- publish() calls added at every point that actually changes what a
  dashboard would want to know: new round opened, round status transitions
  (closing/drawing/paying_out/closed), a bet or withdrawal broadcast, any
  pending tx confirming (bet/withdrawal/payout), a deposit credited, and a
  new block tip arriving (the exact moment the "drawing" phase is waiting on).
- Caddyfile: excludes /rounds/stream from gzip encoding, since compression
  would buffer output and defeat the point of a live stream.

Single-process only by design for now (no cross-worker fan-out) and the
notification is a generic broadcast rather than a per-user channel — both
are deliberate scope decisions for the current ~100-user, single-container
deployment, not oversights. Polling is left fully in place as a fallback;
this is purely additive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:52:07 +02:00
davideandClaude Sonnet 5 6a857f0e07 Show pending-inclusive balance and per-player round outcome reliably
Balance display: place_bet/request_withdrawal spend whole UTXOs and mark
them spent at broadcast time, well before confirmation, so the confirmed-only
balance could drop by far more than the amount actually moving. Add
compute_pending_balance() (app/wallet/balance.py) to fold the unconfirmed
change from in-flight bet/withdrawal PendingTransactions back in; GET
/users/me now returns pending_balance_sats + has_pending, and the frontend
shows it colored green (settled) or amber (still pending) instead of the
confirmed-only figure.

Round outcome display: the win/lose reveal and the "pagamento al vincitore
in corso" status were fighting over the same UI slot, and the reveal broke
across a page refresh. Now:
- The round-status box (generic phase progress) and the personal win/lose
  box are independent and can both be visible at once.
- The win/lose box only renders for users who actually played in that round
  (new user_played field on GET /rounds/current, via a new optional-auth
  dependency so the endpoint stays usable logged-out).
- The reveal delay is anchored to the round's server-provided closes_at
  instead of a client-side "first seen" timestamp, so repeated reloads can't
  reset it, and the revealed result is persisted in localStorage so it
  survives a refresh even after the round has fully closed.
- GET /users/me/last-round-result is a durable DB-backed backstop for
  players who miss the live window entirely (backgrounded tab, offline).

Also hardens the frontend polling loop: call() now times out instead of
hanging forever, and a session-epoch counter stops an in-flight request from
a previous login from resurrecting a duplicate poll loop after logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:09:12 +02:00
davide f822911128 Fix frontend/server round-state desync bugs
- Add a request timeout (AbortController) to the frontend's call() helper, so a
  hung server request no longer freezes the entire polling chain silently.
- Add GET /users/me/last-round-result: a durable, DB-backed fallback for the
  round outcome, since /rounds/current drops winner_user_id the instant a
  round flips from "paying_out" to "closed" — a backgrounded tab or a missed
  poll could otherwise mean a player never learns whether they won.
- Refresh the balance display when a win is revealed (live or via the new
  backstop), instead of leaving the pre-payout balance on screen.
- Guard refreshRound() with a session-epoch counter so an in-flight request
  from a previous login can't re-arm the poll loop after logout, which
  previously produced a duplicate "zombie" polling chain.
2026-07-23 08:53:23 +02:00
davide ad71000777 Show a distinct message per round phase instead of one generic spinner
The draw-state panel and the top chain-status bar previously showed the
same "Estrazione in corso" text for closing/drawing/paying_out alike. Now
each phase gets its own copy (closing: waiting for last bet confirmation;
drawing: waiting for the draw block; paying_out: winner drawn from block
#N, payout in flight), using the round data already returned by the API.

Also drops the now-redundant pastDeadline fast-poll branch: status flips
to "closing" the instant the timer expires (see the scheduler change),
so isDrawing alone already covers that window.
2026-07-22 23:21:04 +02:00
davide 877a219aa4 Distinguish round-closing sub-phases and anchor cooldown to payout confirmation
Flip status to "closing" the instant the round timer expires, rather than
leaving it "open" (invisible) while in-flight bets confirm — /rounds/current
now surfaces this wait as its own phase instead of collapsing it into
"drawing". _tick() is updated to keep re-checking pending bets while status
is "closing" instead of short-circuiting on the old "status != open" guard.

Also re-stamp closed_at at actual payout confirmation time (not at the
earlier "closing" transition), since round_cooldown_seconds counts from
closed_at — with a short cooldown (e.g. 20s) and ~2 block-time draw+payout
wait, the old anchor meant the cooldown had already elapsed by the time the
round actually closed, making it a no-op.

Expose draw_block_height/draw_block_hash on GET /rounds/current so clients
can show which block the winner was drawn from.
2026-07-22 23:20:55 +02:00
davide 3d6f4a98c4 Extract inline CSS from index.html/admin.html into style.css/admin.css
Separates presentation from markup for easier maintenance — no visual
or behavioral change, same rules now loaded via <link rel="stylesheet">.
2026-07-22 23:20:40 +02:00
davideandClaude Sonnet 5 7b3555f8eb Tie the withdrawal minimum to the bet amount instead of a separate field
RoundConfig.min_amount_sats was an independently-configurable floor that
could drift out of sync with bet_amount_sats for no real reason (deposits
never had a server-side minimum anyway). Drop the field and enforce
amount_sats >= config.bet_amount_sats directly in request_withdrawal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 19:25:15 +02:00
davideandClaude Sonnet 5 6c51a81f0e Fail loud at startup if the master key is missing
Previously a missing master.xprv.enc only surfaced as a 500 on the
first register call, with a stack trace that didn't say what to do
about it. Check for the file before alembic/uvicorn start and exit 1
with the exact generate_master_key.py command to run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:46:19 +02:00
davideandClaude Sonnet 5 d92ef9ed9f Drop DATABASE_URL/MASTER_KEY_PATH from .env.example
docker-compose.yml already overrides both to point at the bind-mounted
./data/ dirs, and app/config.py has sensible hardcoded defaults for
local (non-Docker) dev, so neither needs to live in .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:26:52 +02:00
davideandClaude Sonnet 5 1941f30b10 Add ops scripts to decrypt/re-encrypt the master xprv, and document them
decrypt_master_key.py: prints the existing master xprv after an explicit
confirmation prompt, for disaster-recovery backups. Falls back to
./data/keys/master.xprv.enc (the docker-compose.yml bind-mount path) when
.env's configured MASTER_KEY_PATH doesn't exist locally.

encrypt_master_key.py: the reverse direction — takes an externally-generated
xprv (e.g. created offline/air-gapped) via a hidden getpass prompt, validates
it parses as a private extended key, and encrypts it with the same Fernet
scheme generate_master_key.py uses. Refuses to overwrite an existing key file
unless --overwrite is passed.

Neither script is reachable via any API endpoint or the admin panel, by
design — this is the one secret the entire custodial wallet derives from.
Documented in docs/setup.md (new "Recuperare o portare una xprv esistente"
section) and CLAUDE.md's Commands block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:20:03 +02:00
davideandClaude Sonnet 5 9e7b726df7 Use the official PLM logo in the navbar and as favicon
Adds app/static/logo.svg — the official Palladium logo (fetched from the
palladium-coin/palladium-web-site repo), reprocessed for crisp display at
small sizes: the white/blue boundary is sharpened (steep contrast curve
instead of the soft anti-aliasing baked into the low-res source) and the
outer circle uses an analytically computed alpha edge instead of a
rasterized ellipse, so it stays perfectly round with no pixel staircase at
any size. Shape and colors are otherwise identical to the source artwork.

Wired up as the navbar brand mark (replacing the plain amber "P" square) in
index.html, and as the favicon for both index.html and admin.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:39:47 +02:00
davideandClaude Sonnet 5 5f62a8315e Add a "withdraw full balance" checkbox to the withdrawal form
Checking it disables the amount field, autofills it with the current
balance, and keeps it in sync if the balance changes while checked. The
actual withdrawal request uses the raw balance_sats value directly instead
of round-tripping through the PLM input field, avoiding float-rounding
drift when withdrawing the exact full balance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:59:02 +02:00
davideandClaude Sonnet 5 80c413ad3d Show the net prize as the jackpot instead of the gross pool
GET /rounds/current's jackpot_sats was the full pool (participant_count *
bet_amount_sats), which overstates what the winner actually receives once
the 30% commission is taken out at payout time. Apply the same 70% split
rounds/scheduler.py uses so the number shown during the round already
matches the real payout, without surfacing the fee itself in the UI/docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:53:09 +02:00
davideandClaude Sonnet 5 1acb6c3b16 Redesign the user dashboard with a modern fintech-style layout
Restructures app/static/index.html's visual design without touching any JS
logic or element IDs:

- Typography switched to IBM Plex Sans (financial/trustworthy pairing),
  Fira Code kept for addresses and numeric mono values.
- Design tokens extended with elevation shadows, an inset-surface color,
  and a scaled border-radius system; cards/buttons get subtle depth instead
  of flat borders.
- Navigation goes adaptive: a native-app-style fixed bottom tab bar on
  mobile (thumb-reachable), promoted back to an inline tab strip once the
  viewport is wide enough (>=720px) to fit one comfortably under the header.
- Top bar decluttered: balance shown as a highlighted pill (icon + value),
  Guida/Segnala un bug/Esci condensed into icon buttons with tooltips and
  aria-labels instead of full-text links.
- Round-status card gets a subtle gradient "hero" treatment to read as the
  central live widget of the dashboard.
- Content width now adapts from 480px (mobile) to 620px (>=720px) instead
  of staying phone-narrow on desktop; safe-area insets reserved for the
  fixed bottom bar and toast stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:50:17 +02:00
davideandClaude Sonnet 5 2b645f5e37 Show the user's balance in the navbar
Adds a navbar-balance span next to the username, kept in sync by the
existing refreshMe() calls (login, after a bet, after a withdrawal, manual
refresh) — no new endpoint needed, /users/me already returns balance_sats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:36:30 +02:00
davideandClaude Sonnet 5 c4f6f2eed7 Add a help link and bug-report button to the user navbar
Serves docs/guida-utente.md as plain text at GET /guida (no markdown
rendering, kept simple) and links it from the navbar next to a "Segnala un
bug" button. The bug-report link is a placeholder GitHub issues URL
(REPLACE_ME) until a real repo exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:34:03 +02:00
davideandClaude Sonnet 5 67f70465e9 Warn users that withdrawals only support P2WPKH bech32 addresses
embit's address_to_scriptpubkey only knows Bitcoin's network prefixes, not
PLM's — so a legacy P2PKH address (prefix 55, "P...") silently produces a
None scriptpubkey instead of a clear rejection, and P2SH only works by
coincidence. Until the address decoding is fixed to use PLM's own network
prefixes, surface the current limitation to users in the withdrawal form
and the user guide instead of letting them lose funds to a broken address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:29:05 +02:00
davideandClaude Sonnet 5 9fa7eec378 Document the three sequential block confirmations behind the draw/payout timing
CLAUDE.md and the user/admin guides only mentioned "a confirmed block" for
the draw, leaving the actual end-to-end timing (why it can take several
minutes after the countdown hits zero) unclear. Spell out the three distinct
confirmations in sequence — last pending bet, draw block, payout tx — and
the resulting best/worst-case wall-clock estimates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:14:09 +02:00
davideandClaude Sonnet 5 7dcf6d2756 Sync round countdown across clients and enforce the bet cutoff on deadline, not scheduler tick
The round timer relied on each client's own wall clock, so two browsers with
skewed local clocks showed different countdowns for the same round; the
server now also returns server_time so the frontend can correct for clock
skew. Also drop out-of-order /rounds/current responses (multiple independent
triggers could resolve late and revert the UI to a stale drawing/result
state) and prune per-round bookkeeping maps on round transitions.

Separately, place_bet only checked status == "open", leaving a window (up to
the scheduler's 5s tick interval) after a round's timer hit zero where a new
bet could still be accepted. place_bet now checks the round's own deadline
directly (round_accepts_bets), acting as an immediate "yellow light" for new
entries while still letting already-broadcast bets confirm before the round
closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:07:09 +02:00
davide f27fe6243c Harden session handling, add password reset/change, and firm up round polling
Session hardening: / and /admin now respond with Cache-Control: no-store, and
both pages re-derive their auth state on pageshow (event.persisted) as a
safety net against bfcache showing a stale logged-in/out view across
back/forward navigation. The user page also syncs logout across tabs via the
storage event, since localStorage is shared but in-memory JS state isn't.

Password recovery: admin gets a "Reset" button per user (POST
/admin/users/{id}/reset-password) that generates and sets a new password,
shown once — passwords are Argon2-hashed and can never be recovered, only
replaced. Users get self-service password change (POST
/users/me/change-password, requires the current password) under a new
Profilo tab, alongside read-only account info (username, address, balance,
join date).

Round display robustness: the user dashboard now refreshes immediately on
tab visibility change (background tabs get their timers throttled hard),
shows an explicit "connessione persa" state after repeated failed polls
instead of silently freezing on stale data, and polls faster both right when
the countdown hits zero and through the gap where the round is past its
deadline but still waiting for in-flight bets to confirm before the server
actually closes it.
2026-07-22 12:00:09 +02:00
davide 162a63d04a Add a maintenance pause/resume switch and a proper user navbar
RoundConfig gets a paused flag toggled via new POST /admin/pause and
/admin/resume endpoints (audit-logged, surfaced as a "Manutenzione" card in
the admin Parametri view). Pausing only stops the *next* round from opening
once the current one closes — rounds/service.py:open_new_round_if_needed
still lets an in-progress round finish, draw, and pay out its winner
normally. GET /rounds/current exposes lottery_paused so the user page shows
a maintenance banner (even while logged out) instead of silently going idle.

Also replaces the user dashboard's stacked account-bar card + bento-grid
menu with a single sticky navbar (identity row + Deposito/Bet/Prelievo
tabs), and moves the page content into a dedicated .app-shell container so
the navbar itself can span full width.
2026-07-22 10:36:36 +02:00
davide 78109aa4c4 Add a marketing landing hero and a live chain/round status strip
The user page's login screen was a bare test dashboard with no explanation
of how the lottery works; it now leads with a hero (3-step explainer, trust
pills) shown only while logged out, plus a bento-style nav and a glowing
round card during the draw.

Both the user and admin pages now show the current chain tip height and
lottery status (open / drawing / waiting for next round) via a small status
strip, polled from /rounds/current (extended with chain_tip_height sourced
from ElectrumListener.tip_height).
2026-07-22 10:16:51 +02:00
davideandClaude Sonnet 5 8627f3fa0c Persist "closing" as its own committed status
_close_and_draw set round_.status = "closing" but then immediately
overwrote it in memory with "closed" (no participants) or "drawing"
(with participants) before the first session.commit() — so "closing"
was never actually written to the database, only ever visible in the
ORM object's transient in-memory state. GET /rounds/current (and the
frontend's "in chiusura" label) could never observe it.

Committing right after setting "closing"/closed_at, before querying
participants, makes it a real, briefly-observable state like the
others in the round lifecycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:22:29 +02:00
davideandClaude Sonnet 5 4b510f312f Document the draw animation and win/lose reveal
CLAUDE.md's DRAW architecture bullet now explains draw_animation_seconds
and its decoupling from the real block-wait timing. guida-utente.md
gets a new "Estrazione del vincitore" section describing what a player
sees and when. guida-admin.md's Parametri table and hardcoded-defaults
note include the new field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:04:01 +02:00
davideandClaude Sonnet 5 9e1d7da22d Add draw_animation_seconds field to the admin Parametri section
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:03:50 +02:00
davideandClaude Sonnet 5 7e4b603ae3 Show a draw animation and win/lose reveal on the user dashboard
When the round leaves "open" (closing/drawing/paying_out), the round
card swaps its timer for a spinning "Estrazione del vincitore in
corso…" state instead — bets are already rejected server-side once the
round isn't open, this just reflects that visually. Once winner_user_id
is set AND at least draw_animation_seconds has elapsed since the round
started closing (client-tracked per round_id), it reveals "🎉 Hai
vinto! +N PLM" (compared against the user's own id from /users/me) or
"Non hai vinto questa volta.", with a success toast on a win. The
result stays on screen through the cooldown gap and only clears once a
genuinely new round opens (tracked via activeResultRoundId), not the
instant the old round has no active status.

Polling is now dynamic (setTimeout-chained, not setInterval): 3s while
the round is closing/drawing/paying_out for a responsive reveal, 15s
while open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:03:42 +02:00
davideandClaude Sonnet 5 669c3fb714 Add draw_animation_seconds config and expose winner info during a round
RoundConfig gains draw_animation_seconds (default 20) — the minimum
time the frontend's "estrazione in corso" animation plays before
revealing a winner. It's purely a UI cue: the real draw still waits for
a confirmed block for its entropy (rounds/scheduler.py), which usually
takes much longer than this value, so it only ever extends the
animation, never truncates the real wait.

GET /rounds/current now also returns draw_animation_seconds,
winner_user_id and winner_amount_sats (all populated once the
scheduler sets them on the round, i.e. from "paying_out" onward) so a
client can determine and reveal the outcome. GET /users/me now returns
the user's own id, needed client-side to compare against winner_user_id.

Admin config CRUD refactored to a shared field tuple (_CONFIG_FIELDS)
instead of repeating the same 7-then-8 field list three times.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:04:02 +02:00
davideandClaude Sonnet 5 b52a8023de Add README.md
Project overview, quick start (local venv and Docker+Caddy), and a
documentation index pointing to CLAUDE.md, flowchart.mmd and docs/ —
none of that existed as an entry point before this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:38:42 +02:00
davideandClaude Sonnet 5 f23640b6b3 Document the Round/Transazioni pendenti/Audit log dashboard sections
guida-admin.md now covers all five dashboard sections instead of just
Parametri/Utenti, plus an explanation of who actually pays an RBF fee
bump (the transaction's own sender/pool, never the fixed recipient
amount) placed right next to the timeout field it governs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:38:26 +02:00
davideandClaude Sonnet 5 03cdffb34d Update CLAUDE.md for the admin dashboard, Docker deploy and DB-only config
Ran through the init skill's checklist against the current codebase:
test count (49 -> 54), a new "Admin dashboard and test UI" architecture
section (the two static SPAs, their endpoints, and the deliberate
non-linking between them), two new non-obvious domain decisions (admin
privkey export is intentional not a bug; who pays an RBF fee bump), a
pointer to docs/ for the human-facing guides, and updated Known gaps
(deployment gap resolved and removed; admin-token blast radius and the
Docker auto-restart risk called out; user-facing history gap reworded
now that the admin side has one). MVP business parameters reworded to
distinguish what's admin-configurable (bet amount, min amount) from
what's genuinely hardcoded (70/30 split, 1-conf threshold).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:38:17 +02:00
davideandClaude Sonnet 5 0e56f63e63 Document the DB-only config model and the new dashboard sections
CLAUDE.md and docs/guida-admin.md now describe RoundConfig as the sole
source of truth for business parameters, with no env var counterpart —
defaults live as hardcoded model column defaults, not app/config.py.
guida-admin.md documents the four new dashboard sections (Round,
Transazioni pendenti, Audit log alongside Parametri/Utenti). setup.md
points readers to the admin panel instead of .env for those values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:06:51 +02:00
davideandClaude Sonnet 5 d4aadf6b40 Rebuild the admin panel as a full dashboard with a navbar
Replaces the single scrolling page with a sticky top navbar and five
sections: Parametri (all seven RoundConfig fields, now including round
duration/cooldown/min amount/fee rate/RBF timeout alongside fee address
and bet amount), Utenti (unchanged), Round (history: status, winner,
pool/winner/fee amounts, payout txid), Transazioni pendenti (in-flight
bet/payout/withdrawal txs), and Audit log (recent system events with
parsed payload). Everything loads on login; switching a nav tab
refreshes that section's data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:06:38 +02:00
davideandClaude Sonnet 5 ed16e4d50b Add admin endpoints for round history, pending transactions, audit log
GET /admin/rounds: recent rounds with status, winner (joined username),
pool/winner/fee amounts, payout txid. GET /admin/pending-transactions:
in-flight bet/payout/withdrawal txs (RBF candidates). GET /admin/audit-log:
recent audit_log entries with parsed payload. All gated by the existing
require_admin dependency, feeding the new dashboard sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:05:49 +02:00
davideandClaude Sonnet 5 30bde96b6e Move all business/round parameters into DB config, out of env entirely
RoundConfig gains round_duration_seconds, round_cooldown_seconds,
min_amount_sats, fee_rate_sat_vb and rbf_timeout_seconds (plus a
hardcoded default for the pre-existing bet_amount_sats) as column
defaults on the model itself — get_round_config no longer seeds from
Settings at all. Every call site that read these from settings
(scheduler, bets, withdrawals, rounds service/route, RBF bumper) now
reads the DB-backed RoundConfig instead.

app/config.py now holds only true env-driven infra/secrets (database
URL, Electrum connection, master key, JWT, admin token) — no business
parameter has an env var anymore, matching an explicit decision to drop
the "seed from settings" indirection entirely rather than keep a env
fallback nobody should rely on.

Migration backfills the existing round_config row via server_default
(matching the old settings defaults) then drops the default, so future
rows go through the ORM/model defaults instead of a stale constant.

Tests updated: should_bump's timeout_seconds is now required (no
settings fallback); test_scheduler.py seeds a RoundConfig row directly
instead of monkeypatching settings; test_withdrawals.py and
test_rounds_service.py use local constants mirroring the model
defaults instead of reading them off settings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:05:40 +02:00
davideandClaude Sonnet 5 48a9eeb839 Gate the admin panel behind a real login screen
/admin now shows only a token field + "Accedi" button on first load —
config and users are structurally in the page but empty/hidden, no data
requested until the token is verified. On successful login (a GET
/admin/config that doesn't 403) it reveals the dashboard and loads
config + users automatically; no more separate "Carica configurazione"/
"Carica utenti" buttons. Token is kept in sessionStorage (cleared on
tab close) so a reload during the same session skips straight back to
the dashboard. Added a Logout button and Enter-to-submit on the token
field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:28:29 +02:00
davideandClaude Sonnet 5 f6035a888b Add password confirmation and an admin users/privkey panel
Registration now requires the password twice, rejected client-side on
mismatch before hitting the API. The admin page gets a Utenti card:
loads the user list (id, username, address, balance in PLM) and a
per-row "Mostra" button that reveals the private key after an explicit
confirm() — click again to hide it. A persistent warning banner notes
that every reveal is audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:22:03 +02:00
davideandClaude Sonnet 5 39c6ea1950 Add admin endpoints to list users and export a user's private key
GET /admin/users lists id/username/address/balance_sats/created_at.
GET /admin/users/{id}/privkey derives and returns that user's raw WIF
private key, for manual intervention (e.g. sweeping funds back if
something's stuck) — this is already a custodial system, the server
holds the master key everything is derived from, so this doesn't grant
a new capability, just exposes an existing one through the API. Every
access is audit-logged (admin_privkey_accessed).

Also fixes a pre-existing test-isolation bug in test_admin.py's client
fixture: app.db.session.get_session had `from app.db.base import
AsyncSessionLocal`, a one-time reference copy at first import — later
tests reassigning db_base.AsyncSessionLocal never reached it, so any
test mixing direct DB writes with router calls silently read/wrote
against a stale, possibly-disposed engine from whichever test ran
first. Fixed by also rebinding app.db.session.AsyncSessionLocal in the
fixture on every run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:21:55 +02:00
davideandClaude Sonnet 5 f21ecbd4ee Add a cooldown between rounds (ROUND_COOLDOWN_SECONDS)
open_new_round_if_needed now withholds opening the next round until
ROUND_COOLDOWN_SECONDS (default 30) have passed since the previous
round's closed_at, returning None in that window instead of a Round.
Without this, the next round opened within one scheduler tick (~5s) of
the previous payout confirming — not enough time for a player to
notice the round they were in actually resolved.

Callers updated: the scheduler treats None as "nothing to do this
tick", and place_bet raises a "try again shortly" BetError instead of
crashing on a None round.

Not in the original flowchart — a deliberate UX addition on top of it,
documented as such in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:29:59 +02:00
davideandClaude Sonnet 5 abb3418669 Enable the ui-ux-pro-max Claude Code plugin for this project
Used to generate the color palette/typography/UX guidelines behind the
test and admin UI redesign. Checked in so the plugin is enabled for
anyone else working on this repo with Claude Code, not just this
session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:22:35 +02:00
davideandClaude Sonnet 5 784f30ddd7 Add docs/ with separate setup, run, user and admin guides
Four standalone Markdown docs instead of growing CLAUDE.md further:
setup.md (one-time secrets/master-key/migrations), running-the-server.md
(local venv vs Docker+Caddy, dev self-signed vs production domain),
guida-utente.md (dashboard: deposit+QR, bet, withdrawal, round timer/
jackpot) and guida-admin.md (the /admin panel and its API equivalent).
Written in Italian per explicit request, unlike the rest of the
repository's English-only docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:22:03 +02:00
davideandClaude Sonnet 5 6683f197eb Document the Docker + Caddy deployment workflow
Records the docker compose commands (master key bootstrap, up/down,
log tailing) and the SITE_ADDRESS dev-vs-production behavior, plus an
explicit warning: app's restart:unless-stopped means a crash mid-round
auto-restarts into the still-open scheduler-resume gap, so this isn't
unattended-safe yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:15:11 +02:00
davideandClaude Sonnet 5 d35784d574 Add docker-compose stack: app + Caddy reverse proxy with automatic TLS
Caddy's site address comes from SITE_ADDRESS (defaults to "localhost").
Left at that default, Caddy detects it isn't a public hostname and
issues a self-signed cert from its own internal CA — no domain needed
for local/dev testing. Set to a real domain, it gets a genuine Let's
Encrypt certificate automatically instead.

DB, encrypted master key and logs are bind-mounted from ./data/ on the
host (not opaque Docker-managed volumes), so they survive container
restarts/rebuilds and stay reachable for manual inspection/backup
directly from the repo root. ./data/ is gitignored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:15:04 +02:00
davideandClaude Sonnet 5 2dedc283c6 Add Dockerfile for the app
python:3.12-slim, installs the package (pip install .), runs pending
Alembic migrations before starting uvicorn. Migrations/alembic.ini/
scripts are copied in as-is (not part of the installed package) since
alembic and generate_master_key.py run directly against the source
tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:14:56 +02:00
davideandClaude Sonnet 5 63bc1d911b Show round timer, players and jackpot in the dashboard
A card above the section menu polls GET /rounds/current every 15s and
ticks a mm:ss countdown to closes_at every second locally, showing
status (aperto/in chiusura/estrazione/pagamento), participant count
and jackpot in PLM. Refreshed immediately after placing a bet, and
timers are cleared on logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:57:39 +02:00
davideandClaude Sonnet 5 7f2d0bcec6 Add a public current-round status endpoint
GET /rounds/current returns the active round's id/status, opened_at/
closes_at (derived from ROUND_DURATION_SECONDS), participant count and
jackpot (participant_count * bet_amount_sats). No active round still
returns bet_amount_sats so clients can render a fixed-entry hint either
way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:57:31 +02:00
davideandClaude Sonnet 5 7e3eec3e4e Turn the user dashboard into a menu-driven layout with a deposit QR
Deposito/Bet/Prelievo are now separate panels behind a top nav instead
of one long scroll of cards, and the Deposito panel renders the
deposit address as a QR code (via GET /qr/{address}) alongside the
existing copy-to-clipboard address box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:52:06 +02:00