Argon2 is deliberately expensive — tens of milliseconds of CPU per call. Called
inline from the async handlers for register, login, change-password and the
admin reset, that cost froze the entire process for its duration: every other
request, plus all six background tasks (scheduler, confirmation poller, RBF
bumper, listener, both reconcilers). A burst of unauthenticated login attempts
was therefore not just slow logins, it delayed draws and confirmations.
hash_password_async/verify_password_async wrap the existing pair in
run_in_threadpool, and every async caller now uses them. The synchronous
functions stay: they're what the wrappers call, and what tests and scripts (no
running loop) use directly.
The regression test runs a heartbeat task alongside the hashing and counts how
often the loop got to run it — 1 tick with the old inline call, many with the
threadpooled one.
Also drops the running "already fixed and removed" list from BUGS.md: the file
tracks open findings, and `git log --all --grep 'B-nn'` is the record of how a
closed one was closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
client_ip() read the first element of X-Forwarded-For, which is correct only if
the proxy replaces the header. Caddy appends the peer address to whatever the
client sent, so element 0 was whatever the caller claimed: rotating a fake value
per request minted a fresh identity every time and walked straight through the
login and registration throttles (B-33) and the SSE per-IP subscriber cap
(B-38). Only the per-username login bucket, which doesn't key on the IP, still
bit.
Both halves of the audit's fix, since they hold independently:
- the Caddyfile overwrites the header with `header_up X-Forwarded-For
{remote_host}`, so what reaches the app is the actual peer and nothing else.
This is the one that makes the app's assumption true at the source.
- client_ip() reads the *last* hop rather than the first — the element written
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
sits in front of the app (`app` is only `expose`d on the compose network,
never published to the host), so that element is the real peer.
An empty or comma-only header now falls back to request.client.host instead of
returning "", which was its own shared-bucket evasion.
Regression tests both sides: two requests spoofing different prefixes must key
to the same IP, and the Caddyfile must keep the header_up directive (checked by
`caddy validate`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design pass on the bug report page, staying inside the site's existing
design system (tokens, IBM Plex Sans, card/badge/pill components, stroke
icon set) rather than introducing a new one:
- A slim top bar (brand mark + back-to-home pill button + language switcher)
replaces the bare floating heading, so the page reads as part of the
product instead of an orphaned form.
- The "write in English" notice moves inside the form card, right above the
field it applies to, and switches from the amber "needs attention" tone to
an accent-tinted info tone, so it doesn't visually collide with the
bug-status badges' own use of amber for "not read yet".
- "Your reports" is promoted to a proper labeled section with a cleaner row
layout (truncated description with a title tooltip, compact date).
- A character counter on the description field.
- The back-to-home control is now a bordered pill with an arrow icon instead
of a bare text link with a hardcoded "←", which also meant dropping that
hardcoded arrow from all 7 translations.
Also, two content refinements based on feedback:
- Max description length dropped from 5000 to 2000 characters, enforced on
both the textarea and the API's Pydantic validator.
- The "read" status is relabeled from a passive "read"/"letta" to an active
"acknowledged"/"presa in carico" (and each other language's own equivalent
helpdesk term) — it communicates a team is on it, not just that someone
glanced at it. Only the label changed; the underlying "read" status value
in the API/DB is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Turns the /report-bug placeholder into a real form (POST /bug-reports,
optionally attributed to the logged-in user) and adds a "Segnalazioni bug"
section to /admin to view and triage them. A logged-in reporter can also
check their own report's status via GET /bug-reports/mine, since anonymous
submissions have no user to show a history to.
Status is a three-state lifecycle (open -> read -> resolved) rather than a
plain boolean, so an admin can acknowledge a report distinctly from actually
fixing it. The schema went through two migrations because the first one
(add bug_reports table) had already been applied against the running
instance with a `resolved` boolean before the three-state design was
decided, so a follow-up migration backfills it into `status` instead of
rewriting already-applied history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pool_amount_sats * 70 // 100 was hardcoded identically in both
rounds/scheduler.py (the actual payout) and api/routes/rounds.py (the
advertised jackpot). They happened to agree, but nothing enforced it —
changing one without the other would have made GET /rounds/current's
jackpot silently diverge from the real payout. Extract winner_share()
into rounds/service.py as the single source of truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
secrets.compare_digest raises TypeError instead of returning False when a
str argument contains non-ASCII characters, turning a bad admin token into
an unhandled 500 instead of the expected 403. Encode both sides before
comparing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/admin/rounds and /admin/audit-log accepted any limit, including -1 (which
SQLite treats as "no limit"), and /admin/pending-transactions had no limit
at all -- it grows without end. Add Query(default=..., ge=1, le=500) to all
three, plus an optional status filter on pending-transactions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
_wait_for_next_block had no timeout, no log, and no audit entry: a
connection that stopped advancing the tip left a round silently frozen
in "drawing" with nothing in /admin to explain why. Log progress
periodically, write a draw_stalled audit entry past a threshold (a few
block-time multiples), and surface the wait via a new Round.drawing_started_at
column, exposed as draw_waiting_since in GET /rounds/current.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SQLite/aiosqlite returns DateTime columns as naive even though every
value is written in UTC, so a bare .isoformat() dropped the offset and
the frontend's new Date() parsed it as local time. Add a shared
isoformat_utc() helper and use it at every call site that was missing
the fix already applied ad hoc in rounds.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither self-service password change nor the admin reset invalidated
already-issued JWTs — a 24h-lifetime token stayed valid regardless, so
a stolen token (or an attacker who already had the old password) kept
working past a password change meant to lock them out. The admin reset
exists precisely for the "account compromised" case and didn't evict
the attacker at all.
Add User.token_version (migration 943dbd74d983), embedded in every JWT
as a "tv" claim and checked against the DB on every request in
get_current_user/get_optional_user; a mismatch reads as session_expired.
Both change-password and the admin reset bump it. change-password hands
back a freshly minted token so the caller's own session keeps working
instead of being logged out by its own request; the admin reset does
not, since that session isn't the one making the call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
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>
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>
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>
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>
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>
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>
- 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.
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.
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>
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>
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>
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.
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.
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).
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>
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>
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>
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>
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>
GET /qr/{address} renders the address as a PNG QR code (qrcode[pil]),
gated by a bech32-shaped regex since it's otherwise unauthenticated —
the address itself isn't sensitive, but this keeps it from being used
as an arbitrary text-to-QR service.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bearer-token-gated admin endpoints to read/update the DB-backed
operational config (fee_address, bet_amount_sats) without a redeploy,
plus a lightweight audit log writer for round/payout/config events.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Builds and broadcasts a user->external-address PSBT with change back to
the user's own address, fee deducted from the withdrawn amount, and
registers the confirmation handler that marks the withdrawal confirmed.
Shares the per-user lock with bets so a build never races a spend from
the same UTXO set.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Places the fixed-cost bet into the current round: builds and broadcasts
the user->pool PSBT with change back to the user's own address, enforces
at most one active bet per user, and registers the confirmation handler
that marks a bet confirmed and adds the participant to the round.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Argon2 password hashing, JWT session issuing/verification
(auth/security.py), register/login routes, the bearer-token
get_current_user dependency, and GET /users/me for address + balance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>