Files
plm-lottery/CLAUDE.md
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00

58 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Language

The user communicates in Italian in chat — reply to them in Italian. Everything written to the repository (code, comments, commit messages, docs, this file) must be in English. Reasoning/thinking should also be done in English.

Project status

All 10 stages of the original build order are code-complete and unit-tested — 697 tests, all under tests/unit/ (tests/integration/ is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (/admin), static test UI (/), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.

Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need deposit_confirmations), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). Withdrawal and the RBF bump path have never been exercised against a live broadcast — unit-tested only.

Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are all fixed as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked BUGS.md, which lists only what is still open (B-71 … B-98 at the time of writing). The spec in docs/ already describes the target behaviour for all of them — every affected requirement is marked *Stato:* da implementare (B-nn) with an *Oggi:* line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in docs/piano-correzioni.md; the full history of every B-nn, open and closed, is in docs/storico-bug.md. Closed findings leave the file, so the ~276 B-nn markers left in comments across the code are pointers into git history (git log --all --grep 'B-nn' finds the commit that fixed one, and git show f1a1145:BUGS.md-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (tests/integration/ is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted by design instead. A new finding gets the next B-nn, in its own commit with its own regression test.

docs/ is the specification, and it is authoritative. Every verifiable rule there has a stable requirement ID (RND-04, BET-02, …) with *Codice:*/*Test:* pointers to what implements it; the index is docs/come-funziona.md and the ID convention and change workflow are in docs/processo-di-modifica.md. A behaviour change starts in the spec: edit or add the requirement, implement it, cite the ID in the test docstring and the commit message, and ship spec + code + test in one commit. If this file and the spec disagree, the spec wins and this file is the one to fix. Before writing code, read the relevant spec documents and the "Architecture" section below.

Human-facing guides are in docs/, in Italian by explicit request (an exception to the English-only rule): the specification starting at come-funziona.md, the operational guides setup.md, running-the-server.md, guida-utente.md, guida-admin.md. README's Quick start and docs/running-the-server.md are Docker-only, matching this file — a bare uvicorn --reload workflow was removed from both (B-44).

Documentation map (docs-first, SSOT, ADR)

Entry point: docs/README.md — the index of everything under docs/. Three rules, all in docs/processo-di-modifica.md:

  1. Docs-first. Behaviour changes start in docs/, never in the code. Spec + code + test ship in one commit, with the requirement/ADR ID in the test docstring and the commit message.
  2. SSOT. Every fact has one owner; everything else links to its ID instead of restating it. Why → an ADR (docs/adr/); what (verifiable rule) → a PREFIX-NN requirement in the domain document; how → the code; accepted limits → limiti-noti.md (LIM-nn); found/fixed defects → storico-bug.md (B-nn, open ones in BUGS.md). If two documents disagree, the spec wins and the other one gets fixed. This file included: it summarises, it does not own.
  3. One ADR per feature/decision. A new feature, or a change of an existing choice, starts with an ADR from docs/adr/TEMPLATE.md (next free ADR-NN, never reused or rewritten — a superseded one becomes "Sostituita da ADR-MM"), then its requirements, then the code. Add it to the area index and the feature → ADR table in docs/adr/README.md. Before touching a feature, read its ADRs there.
Need Go to
Why is it built this way / alternatives rejected docs/adr/README.md (the old DEC-nn are now ADR-nn, same numbers)
What exactly must it do domain docs listed in docs/README.md (RND, BET, WDR, DEP, REG, TX, ELC, AUD, ARC, DAT, API, SEC, OPS)
Narrative overview docs/come-funziona.md
Accepted limits docs/limiti-noti.md
Fix order for open B-nn docs/piano-correzioni.md

Commands

The server always runs via Docker, in dev and prod alike — there is no supported way to run uvicorn directly. The venv (.venv/) is only for local tooling: tests, Alembic migrations, and the one-time key/secret scripts.

source .venv/bin/activate                            # venv already created at .venv/
pip install -e ".[dev]"

alembic revision --autogenerate -m "message"          # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand

PYTHONPATH=. python scripts/generate_master_key.py    # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env)
PYTHONPATH=. python scripts/decrypt_master_key.py     # ops recovery: decrypt+print it (asks for confirmation)
PYTHONPATH=. python scripts/encrypt_master_key.py     # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py    # manual check: connect, handshake, subscribe to headers, print the tip

python -m pytest                                      # all 697 tests
python -m pytest tests/unit/test_hd.py                # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic   # one test

asyncio_mode = "auto" (pyproject.toml), so async tests need no @pytest.mark.asyncio.

.env (gitignored) holds the real secrets; .env.example documents the required keys and how to generate each. Note it does not list DATABASE_URL or MASTER_KEY_PATH, which the real .env does set.

Deployment (Docker + Caddy)

Same docker-compose.yml for dev and prod — only SITE_ADDRESS differs. Two containers: app (this codebase; its startup command refuses to start if the master key file is missing, then runs alembic upgrade head and uvicorn) and caddy (reverse proxy + automatic TLS). The compose file overrides DATABASE_URL/MASTER_KEY_PATH inside the container to point at the bind-mounted ./data/ (db, encrypted key, logs — gitignored, survive restarts). Set MASTER_KEY_PATH in .env to the host-side ./data/keys/master.xprv.enc so the venv scripts write the exact file the container reads — one source of truth for the key.

mkdir -p data/db data/keys data/logs        # one-time
docker compose up -d --build                # dev and prod alike
docker compose logs -f app                  # also written to ./data/logs/app.log
docker compose down

SITE_ADDRESS unset → localhost, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; curl -k). SITE_ADDRESS=lottery.example.com docker compose up -d → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).

The Caddyfile sends baseline security headers — HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, and a CSP scoped to default-src 'self' plus the Google Fonts @import in style.css/admin.css. It also overwrites X-Forwarded-For with the real peer (header_up X-Forwarded-For {remote_host}, B-54) — Caddy otherwise appends to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; app/api/client_ip.py independently reads the last hop, so either half closes it. script-src/style-src need 'unsafe-inline' because both SPAs use inline onclick handlers and style="" attributes throughout — removing those is a separate, larger refactor, not a header change. restart: unless-stopped on app means a mid-round crash auto-restarts: closing, drawing and paying_out all resume on their own (B-81: _tick re-enters _complete_draw, which rebuilds everything from the database, so a resumed draw yields the same winner).

Tech stack

  • Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, embit for keys/PSBT/tx parsing.
  • PLM access via the Electrum protocol only (no full node/P2P). Dev bootstrap server: santantonio.sytes.net:50002 (SSL).
  • Auth: Argon2 hashing + JWT (HS256, 24h). Tokens are revocable (B-34): the token carries a tv claim, User.token_version is bumped by a self-service password change and by an admin reset, and get_current_user/get_optional_user reject any token whose tv no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to jwt_expire_minutes. A token predating the claim decodes as tv = 0, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through hash_password_async/verify_password_async (run_in_threadpool, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
  • Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). validate_runtime_secrets() (app/config.py, called from the lifespan — deliberately not a Settings validator, so imports and tests need no real secrets) makes the server refuse to serve if JWT_SECRET < 32 chars or XPRV_ENCRYPTION_KEY is empty. An empty ADMIN_TOKEN is deliberately non-fatal: require_admin then denies everything, i.e. a locked panel, not an open one.
  • Operational config lives in the DB, not in env vars: every business/round parameter is one row of round_config (app/rounds/config.py), editable live from /admin — no redeploy, no restart. Defaults for a fresh instance are column defaults on RoundConfig (app/db/models.py), not app/config.py. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in .env, since those need a restart anyway.

PLM network parameters (mainnet)

Source of truth: the PalladiumWallet repo — ChainProfiles.cs, PalladiumNetworks.cs. Re-check there for anything not listed here rather than guessing; its ChainProfiles.Mainnet.BootstrapServers is also where .env.example's suggested ELECTRUM_FALLBACK_SERVERS come from.

BIP44/84 coin type 746 → m/84'/746'/0'/0/index
Bech32 HRP plm
P2PKH / P2SH version byte 55 (addresses start with P) / 5
WIF prefix 0x80
Block time 120s
BIP32 ext-key headers see ExtKeyHeaders in ChainProfiles.cs

Business parameters

Parameter Value Where
Bet cost 10 PLM (bet_amount_sats = 1_000_000_000) RoundConfig, admin-editable, snapshotted onto Round.bet_amount_sats when a round opens (B-95) — an edit applies from the next round
Prize split 70% winner / 30% platform, rounding remainder to the platform; the platform's share pays the bump reserve (max(294, vsize(inputs, 3 outputs) × 10) sat, B-79) to the reserve address 1/0 and the rest goes to fee_address hardcoded in rounds/service.py:winner_share / wallet/psbt_builder.py:payout_bump_reserve — a code change, not an admin edit
Round duration / cooldown 600s / 30s RoundConfig, but snapshotted onto Round.duration_seconds/cooldown_seconds when a round opens (B-61) — an edit applies from the next round, never to the one in progress
Draw animation 20s (cosmetic frontend minimum only) RoundConfig
Draw confirmations 3 blocks on the draw block (draw_confirmations, 1–20) RoundConfig, admin-editable, snapshotted onto Round.draw_confirmations at open (B-73)
Commission address fee_address RoundConfig, admin-editable, snapshotted onto Round.fee_address at open (B-95): the payout pays the round's own, never the live one
Pool cap max_round_pool_sats, 0 = none (otherwise ≥ the bet cost, ≤ 4·10^15) RoundConfig, snapshotted onto Round.max_round_pool_sats at open (B-95); place_bet refuses with round_full (params max_round_pool_sats) once the sum of the round's participant rows has reached it, in-flight bets included — the pool can end up above the cap by at most one bet
Fee rate / RBF timeout 1 sat/vB / 900s RoundConfig
Min withdrawal = current bet_amount_sats (no separate field) withdrawals/service.py
Min deposit none —
Min password length 8 auth/security.py:MIN_PASSWORD_LENGTH
Confirmations 1 for every platform-built tx (confirmation poller) and for the outputs it creates (bet/withdrawal change, payouts); deposit_confirmations (6) for a deposit, 120 for a coinbase output (B-82) tx confirmation hardcoded in tx/confirmation.py; deposit threshold in round_config (/admin), coinbase maturity hardcoded in deposits/service.py
Max inputs per user tx (bet, withdrawal) 50 (MAX_TX_INPUTS, B-48) — over it the build fails with too_many_inputs, it never spends more hardcoded in wallet/psbt_builder.py
Max inputs per payout 500 (MAX_PAYOUT_TX_INPUTS, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable hardcoded in wallet/psbt_builder.py
Max participants per round 400 (MAX_PARTICIPANTS_PER_ROUND, B-52) — the 401st bet is refused with round_full before any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time hardcoded in wallet/psbt_builder.py, enforced in bets/service.py

GET /rounds/current's jackpot_sats is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual bet_amount_sats (each already net of its own bet fee) rather than count × current bet amount — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11). It counts confirmed participants only (B-65), matching what the draw picks from and what the payout can spend, with pending_participant_count/pending_jackpot_sats/has_pending_bets reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like pending_balance_sats (see "Balance display"). /'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid.

Round cooldown (round_cooldown_seconds, not in the original design): gap after a round closes before the next opens, so players can see the outcome.

Maintenance pause (RoundConfig.paused): toggled by POST /admin/pause / POST /admin/resume — a deliberate operator action with its own "Manutenzione" card in /admin, audit-logged lottery_paused/lottery_resumed, not a plain config field. It only stops the next round from opening (rounds/service.py:open_new_round_if_needed); a round in progress still closes, draws and pays its winner. Exposed as lottery_paused so / can show a banner, with paused_reason (B-78, DAT-69): operator, or an automatic reason the system wrote itself (resurrected_tx, reorg, payout_blocked, rounds/pause.py) — an operator pause never overwrites an automatic one, and resume clears whichever it is.

No fee_address, no rounds (rounds/service.py:rounds_can_open, B-66): the payout pays the 30% commission to fee_address, which has no column default because an operator must set their own — so until they do, open_new_round_if_needed refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in paying_out with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as lottery_configured on GET /rounds/current — / shows a different banner from the maintenance one, since "come back later" would be false — and as a warning on /admin's Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in rounds_can_open next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: payout_config_problem(bet_amount_sats, fee_rate_sat_vb) checks a round of 1 and of MAX_PARTICIPANTS_PER_ROUND participants against the winner's network fee, the bump reserve and dust outputs, and PUT /admin/config refuses a bet amount or fee rate that fails it with 422 config_not_payable (judged on the resulting values and, for a fee-rate change, on the in-progress round's own snapshotted cost as well; B-95 adds 422 max_round_pool_below_bet_amount for a cap below the cost of one bet) (the payout reads fee_rate_sat_vb live, so raising it mid-round is exactly what could otherwise strand the round in progress).

Code map

Package Contents
app/main.py entry point: lifespan starts the six background tasks, mounts the routers and app/static/
app/api/routes/ admin, bets, withdrawals, rounds (incl. SSE), users, qr, bug_reports; app/api/errors.py holds the error contract and app/api/client_ip.py the trusted-peer extraction every IP-keyed control uses
app/auth/ routes (register/login), Argon2 + JWT (security.py), get_current_user/get_optional_user, login/registration throttling (rate_limit.py)
app/db/ models.py (all tables + the active-round index), engine/session factories
app/wallet/ HD derivation (users 0/i, per-round pool 2/<round_id>, reserve 1/0) + WIF export (hd.py), PLM network constants, address/scripthash, balance math, psbt_builder.py (build/sign bet, withdrawal, payout; select_utxos)
app/electrum/ client.py (JSON-RPC, endpoint parsing, timeouts), listener.py (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting)
app/deposits/ crediting / external-spend detection / reinstatement (service.py), periodic sweep (reconcile.py)
app/bets/, app/withdrawals/ build+broadcast services and their confirmation handlers
app/rounds/ scheduler.py (close/draw/payout), service.py (open/active-round rules), draw.py (header math, powLimit, commit-reveal seed + winner pick), config.py, events.py (SSE pub/sub)
app/tx/ broadcast.py (RBF bumper), outcome.py (broadcast verdict, B-76), versions.py (every txid of a row, B-77), confirmation.py (poller + handler registry), reconcile.py, locks.py (per-user locks)
app/static/ the two SPAs (index.html/app.js/style.css, admin.html/admin.js/admin.css) + i18n.js

Background tasks

app/main.py's lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals.

Task File Cadence Role
ElectrumListener electrum/listener.py reconnect loop, 60s keepalive the single connection; subscribes headers + every user's scripthash, credits deposits
RoundScheduler rounds/scheduler.py 5s opens/closes rounds, draws, triggers and retries payouts
ConfirmationPoller tx/confirmation.py 10s pending → confirmed via per-kind handlers registered by app/{bets,rounds,withdrawals}/confirmation.py — imported for that side effect in main.py, don't "clean up" those imports
RbfBumper tx/broadcast.py 30s fee-bumps anything past rbf_timeout_seconds
PendingTransactionReconciler tx/reconcile.py at startup, then 120s resolves building/pending rows against the chain
DepositReconciler deposits/reconcile.py 300s (sleeps first) re-refresh_users every address, catching a silently-lost subscription (B-30)

Chain access goes through listener.client, passed as lambda: listener.client so a reconnect swaps the client under its consumers; a task finding it None skips that cycle instead of failing. A non-null client means the tip is already known: _run_once publishes it only after the first header has been applied (B-63), so client is not None can be read as "the chain is reachable and we know where it is" — tip_height is never the initial 0 behind a live client, which is what the draw depends on (see DRAW below). DepositReconciler takes the whole listener instead, reusing refresh_user so the periodic and notification-driven paths can't diverge.

Electrum connection

One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:

  • Rotation. ELECTRUM_HOST/PORT is primary, ELECTRUM_FALLBACK_SERVERS a comma-separated host:port[:notls][:pin=<sha256>] list (client.py:parse_endpoints rejects malformed entries at startup, not during the outage when the fallback is needed; pin= fixes the server's DER certificate hash, ELECTRUM_CERT_PIN does the same for the primary, B-84). The server refuses to start with fewer than 2 fallbacks (config.validate_quorum_servers, B-84; ALLOW_NO_QUORUM=true overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
  • Bounded requests (_REQUEST_TIMEOUT_SECONDS = 15s); a timeout tears the connection down. Unbounded waits used to hang POST /bets while holding the per-user lock, and could stall the confirmation poller permanently.
  • The drop is observable: client.wait_closed() resolves when the read loop dies, and _run_once races it against the notification consumers and a 60s server.ping. A server that keeps answering pings but stopped following the chain is caught too (B-86): at every ping, if no header has been accepted for 360s (_STALL_SECONDS), _check_not_stalled asks the other servers for their tip and ends the session with ServerStalledError if any is higher, so run() rotates; none higher means a quiet chain and the session goes on. Without it the listener sat on queues nobody would ever fill while listener.client still looked alive.
  • Headers are validated, not trusted (_apply_header, async since B-74): the tip never regresses, a header must meet the difficulty target it claims and that target must be no easier than draw.POW_LIMIT (PLM's powLimit; tests/unit/conftest.py swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put MAINNET_POW_LIMIT back), and every advance must be linked to the tip: a single block by its prev_hash, a jump by downloading the headers in between (blockchain.block.headers, ≤2016 per request) from the active server and checking each. Failure raises HeaderValidationError, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (_window, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (_follow_reorg), a fork older than the window ends the session. A followed reorg writes chain_reorg_detected before the window moves (_record_reorg, B-85), and a depth of 2 or more pauses the lottery with paused_reason = 'reorg' in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused without ending the session (B-64): one carrying no hex at all (nothing to validate or draw from, and applying the height alone would break the tip_height/tip_header_hex pairing). _run_once separately refuses to publish the client while no tip is known, so the ignore-don't-kill choice can't reopen B-63.
  • A quorum corroborates every money-moving decision (_corroborate_majority, 10s per server, asking only the other endpoints — never the active one, which is what a MITM controls): corroborate_header before a block seeds the draw (B-28), corroborate_utxo_spent before a UTXO missing from one listunspent is written off as externally spent (B-29), and corroborate_utxo_credit before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes corroborate_utxo_reinstatement before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; deposits/service.py:find_reinstate_candidates only reads, reinstate_reappeared_utxos writes what the quorum agreed on), and confirmation_proven before a tx is marked confirmed: tx/confirmation.py:poll_once takes the height the active server reports only as a claim, and the row confirms when a strict majority of at least 2 of the other servers return a get_merkle proof that recomputes to the merkle root of the block's header (electrum/merkle.py; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no network nothing confirms. Since B-82 the pool-coin check before a payout is on the quorum too (verify_pool_outpoints, below). A quorum needs at least 2 answers and a strict majority of those that answered (B-84): fewer → returns False, since one voice decides nothing independently and an unreachable network proves nothing. Only ALLOW_NO_QUORUM=true keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only delays: find_new_credit_candidates re-offers the outpoint on the next refresh or DepositReconciler sweep.

On reconnect _subscribe_all_users runs as its own task with bounded concurrency (_RESUBSCRIBE_CONCURRENCY = 20) rather than inline and serially — otherwise a large user base froze tip_height, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. address_for_new_user (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or DepositReconciler sweep.

Architecture — the 5 phases

Diagrams: come-funziona.md (the 5 phases), ciclo-di-vita-del-round.md (the round/draw lifecycle).

REG — on signup the server derives a P2WPKH address via BIP84 (m/84'/coin'/0'/0/index, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.

DEP — the listener subscribes to the user's scripthash; balance is credited once the output reaches its threshold (B-82, DEP-03: 1 if a transaction of the platform created it — any txid of a pending_transactions row —, 120 for a coinbase, recognised from the raw tx that must hash to the txid, ElectrumListener.is_coinbase, and round_config.deposit_confirmations, default 6, for everything else) and only once the other servers corroborate the outpoint, its amount and a height that gives it that many confirmations on our tip (B-59, ELC-17). The deposit is the one input a user can double-spend, hence the 6. Outputs short of their threshold are pending deposits (ElectrumListener._pending_deposits, memory only): shown in pending_balance_sats (amber), never spendable, and since no scripthash notification announces a maturing deposit each new block refreshes only the users that have one (_refresh_waiting_deposits). A deposit that vanishes later takes the spent-outside path; there is no other rollback logic. deposits/service.py also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and reinstates ones that reappear.

PLAY — fixed cost, at most one active bet per user, and at most MAX_PARTICIPANTS_PER_ROUND (400) players per round — past that the bet is refused with round_full and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a change output back to the same user address of at least DUST_LIMIT_SATS — a user's balance must never exactly equal the bet, and since B-62 that's enforced (balance_leaves_no_change) rather than assumed. Fee ~1 sat/vB, deducted from the bet amount. No confirmation within the timeout → RBF bump and rebroadcast.

DRAW — configurable timer (default 600s):

  • Bet cutoff is the round's own deadline (round_deadline = opened_at + Round.duration_seconds, the value snapshotted at open time — B-61), not the DB status: place_bet calls rounds/service.round_accepts_bets, which rejects once the deadline passes even while status is still "open" (the 5s scheduler tick can lag behind it). Once a round leaves open, no new bets either, and no new round opens until this one is fully closed. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a compare-and-set on the round row (UPDATE rounds ... WHERE status = 'open', B-53): the scheduler flips open → closing in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only confirmed participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is _close_and_draw re-counting in-flight bets in the same session it snapshots the participants from.
  • "Yellow light": closing waits for every already-broadcast bet to confirm before drawing, so a bet in flight at the boundary isn't lost (building counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
  • Algorithm (deliberately simple, meant to be replaced; commit-reveal since B-73, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte server_seed — stored only Fernet-encrypted with the master-xprv key (server_seed_enc; the plaintext column server_seed stays NULL until the reveal) — and publishes server_seed_hash = sha256(seed). Entering drawing fixes, in one transaction with the status change, the confirmed participants ordered by bet txid ascending (public data, no ties — never broadcast_at), each one's draw_position, participants_hash (sha256 of txid:vout:address\n lines; bet_vout is the output paying the round's pool address), and draw_height = tip_at_close + 1, and writes draw_committed. The draw waits until the chain has draw_confirmations blocks from draw_height (snapshotted at open from round_config, default 3), then asks every server, the active one included, for the header at that exact height (ElectrumListener.agreed_block_hash: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (draw_header_corroboration_failed, once per distinct hash the active server reported). seed = sha256(server_seed ‖ block_hash ‖ participants_hash) as a big-endian integer, index = seed mod n, winner = participant with that draw_position; equal probability for everyone, regardless of amount. The reveal (server_seed column + winner_drawn payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and GET /rounds/{id}/proof (public) returns everything needed to recompute it. A tip of 0 at closing means unknown: the round stays closing (draw_baseline_tip_unknown, once per round, B-63) because draw_height must come from a height we actually knew. draw_stalled fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. _complete_draw reads everything it needs from the database (_close_and_draw enters drawing and then calls it, and since B-81 _tick calls it again for any round found in drawing — after an exception or a restart — so it is idempotent: a round no longer in drawing, or already holding a winner, is left alone); a round opened before B-73 (no server_seed_enc) is given a secret as it enters drawing.
  • Payout is signed with the round's own pool key — every round has its own pool address m/84'/746'/0'/2/<round_id>, stored in rounds.pool_address when the round opens (B-80; derive_pool_address(round_id), derive_round_pool_key), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address 1/0, or to fee_address) is refused with withdrawal_to_platform_address. Rounds opened before B-80 keep the old shared 1/0 address as their pool; it is now the reserve address. The payout's network fee comes out of the winner's 70%; the platform's 30% pays the bump reserve (B-79, RND-38: always a third output, to the reserve address 1/0, recorded in rounds.bump_reserve_sats) and the remainder goes to fee_address. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and bump_fee shrinks it (_find_reducible_output: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern. Verified before it is built (B-82, RND-22, RND-39, _trigger_payout → _verify_round_pool → ElectrumListener.verify_pool_outpoints): a round with its own pool address spends exactly its confirmed participants' bet outputs (bet_txid:bet_vout), never whatever else sits at the address, and first asks the other servers whether each is still there — a strict majority of at least 2 answers; a mempool-only coin or a tie concludes nothing (payout_failed, pool verification unavailable, retried). A participant whose bet is gone becomes reorged_out, participant_reorged_out is audit-logged, pool_amount_sats is rewritten (only ever downward) in the same transaction and the 70/30 split is computed on what is left. If the winner's own bet is gone nothing is built: payout_blocked_at is set, payout_blocked is logged and the lottery is paused with paused_reason = 'payout_blocked'; the winner's row stays confirmed so that after POST /admin/resume (which clears the mark) the verification really runs again. A round opened before B-80 (pool = the shared reserve address) keeps the old active-server listunspent choice.
  • UI, two independent layers. A generic phase box ("Pagamento al vincitore in corso…") shows to every viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by status. Additively, a personalized "Hai vinto!/Non hai vinto" box appears only where user_played is true (computed via get_optional_user, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
  • Reveal timing. Delayed by at least draw_animation_seconds, anchored to the server's closes_at so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for winner_user_id. Once revealed it's persisted in localStorage.plm_persisted_result, surviving the move to closed — at which point get_active_round stops returning the round and winner_user_id disappears from GET /rounds/current. GET /users/me/last-round-result is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: refreshRound/checkLastRoundResult in app/static/app.js.

WITHDRAW — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern. A full-balance withdrawal moves balance - DUST_LIMIT_SATS so the change output (and with it the ability to fee-bump) always exists — Withdrawal.amount_requested_sats vs amount_sent_sats is what records the difference (B-62).

PLAY and WITHDRAW share a per-user lock (tx/locks.py): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set. That is the only mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus place_bet/request_withdrawal only ever offering select_utxos UTXOs with spent_txid IS NULL is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything.

Three separate on-chain confirmations sit between the timer hitting zero and the payout landing — a common point of confusion:

  1. Last bet's confirmation — the round doesn't even flip to "closing" until every broadcast bet has 1 conf (_tick's pending_count check). May already have happened before the deadline.
  2. The draw block — fixed at draw_height = tip_at_close + 1 (recorded only once step 1 is done, so necessarily a later block) and read once the chain has draw_confirmations blocks from it (3 by default, so it is really ~3 blocks, not one).
  3. Payout confirmation — built only after step 2's winner is known, so it needs yet another block; the generic ConfirmationPoller tracks it.

At 120s blocks that's ~8–10 min worst case (last bet confirms right at the deadline), ~6–8 min best case with the default 3 draw confirmations — independent of draw_animation_seconds.

Balance display

place_bet/request_withdrawal select whole UTXOs (select_utxos, largest-first) and mark each spent_txid at broadcast time, long before any confirmation. cached_balance_sats (recompute_balance) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving.

compute_pending_balance (app/wallet/balance.py) fixes the displayed number without changing what's spendable: it decodes the raw tx of every in-flight (pending) bet/withdrawal for the user and adds back the outputs paying to the user's own address. GET /users/me returns both — balance_sats (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and pending_balance_sats + has_pending (what the UI shows: green when settled, amber while pending). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets balance_pending_confirmation instead of a flat insufficient_balance (B-37), so the error doesn't contradict what the user is looking at.

Real-time updates (SSE)

GET /rounds/stream is additive to the polling loops in the two SPAs, not a replacement — a blocked or dropped stream just degrades to the old behaviour. No payload, no auth: it's a "something changed, go refetch" ping, with all personalization (e.g. user_played) staying in the authenticated REST endpoints. The generator re-checks request.is_disconnected() every 5s and sends a keep-alive comment every 20s, so neither a client that vanished without a clean close nor a proxy idle timeout breaks it silently.

rounds/events.py's RoundEventBroadcaster (singleton broadcaster) is in-process pub/sub, one asyncio.Queue(maxsize=1) per client so redundant notifications coalesce. publish() is called on: a round opening (rounds/service.py), every status transition (scheduler.py), a bet or withdrawal broadcast, any pending tx confirming (tx/confirmation.py), a deposit credited (deposits/service.py), and a new tip arriving (electrum/listener.py — exactly what the drawing phase waits on). The rollback paths (_release_failed_bet, _release_failed_withdrawal, the reconciler's abandon) publish too — a rollback moves as much state as the success path, so it must ping the dashboards the same way (B-49).

Deliberate scope limits, not oversights: single-process only (fine for one uvicorn process; a multi-worker deployment needs e.g. Redis pub/sub — don't add it speculatively); generic broadcast, not per-user (everyone refetches on every event; acceptable at ~100 concurrent users, and a targeted channel would need auth on the stream plus server-side knowledge of who each event affects); MAX_SUBSCRIBERS (500) is defensive only — past it the endpoint returns 503 and EventSource falls back to polling, which being global and unauthenticated makes the cap itself a cheap DoS of the realtime feature (B-38).

Both SPAs refresh on an update message or on open — the latter fires on every automatic reconnect, closing most of the "missed while disconnected" gap.

Transaction lifecycle and reconciliation

Everything that spends money is written before it is broadcast and resolved against the chain afterwards; this is what makes the system recover without manual DB edits. PendingTransaction.status: building → pending → confirmed, or failed.

  • building is written first, UTXOs already marked spent_txid, and committed before the broadcast (bets/service.py, withdrawals/service.py, and scheduler.py:_trigger_payout — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record.
  • Nothing is released on doubt (B-76). tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused / uncertain. Only an explicit JSON-RPC error (ElectrumRpcError) that ElectrumListener.transaction_known then confirms — the active server and the quorum minimum of the others all say they do not know the tx — releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal failed), audit-logs, and raises broadcast_failed → 502. If any server returns the tx, or the message is an "already known" one, it counts as accepted. A timeout, a dropped connection or a refusal that could not be verified leaves the rows building for the reconciler and the API answers 202. An accepted tx is also fanned out to every other server (fan_out_transaction, best effort).
  • tx/reconcile.py asks the quorum about anything still building/pending (every txid of the row, on the active server and every other one): present on any server → promote (and adopt the version found); otherwise nothing is given up without proof (B-78, ADR-51): a row is abandoned — failed with the proof in failure_reason, inputs released, domain row rolled back, pending_tx_abandoned logged — only if one of its inputs is spent in a block by another tx and the other servers agree (confirmed_spender), or no server knows any txid and a rebroadcast of its latest raw tx (rebroadcast_everywhere) is refused by every server for missing/spent inputs. A fee refusal is not proof (building → pending for the bumper); too few answers, a transport failure or the age of the row prove nothing. Grace only decides from when a row is examined (120s building, 6h pending). The write re-checks status and current_txid under begin_immediate (TX-26). failed rows stay watched for 14 days (failed_at): one that confirms late is marked confirmed, pending_tx_resurrected is logged and the lottery pauses (paused_reason = 'resurrected_tx', rounds/pause.py); the linked rows are never corrected by the system. A tx still in some server's mempool is never abandoned, so in practice that is what bounds the closing wait.

SQLite transactions are real ones (B-88). _register_sqlite_pragmas sets synchronous=FULL and switches pysqlite's implicit BEGIN off (isolation_level = None); an engine begin event emits BEGIN itself, so every read is inside a transaction. Every transaction that moves money or fixes its state opens with await begin_immediate(session) (app/db/base.py) — BEGIN IMMEDIATE, i.e. the write lock before the first read (it commits the caller's read-only transaction first and refuses a session with unflushed changes). A lock not granted within busy_timeout raises OperationalError: before a broadcast that means "refuse" (service_busy, 503, nothing built or sent); after one nothing is undone — the row stays building and the reconciler resolves it. New money-moving write code must call begin_immediate first, and tests of that must use a file DB whose engine went through _register_sqlite_pragmas (an in-memory engine without it never emits BEGIN IMMEDIATE).

UtxoEvent.spent_txid must always equal the tx's current txid, so bump_fee retargets it along with RoundParticipant.bet_txid, Withdrawal.txid and Round.payout_txid on every bump. Every version a row ever had is tracked (B-77): pending_transaction_txids (app/tx/versions.py) holds each txid with its raw tx, written with the row and, for an RBF replacement, before it is broadcast; the poller and the reconciler look for all of them, and when the network holds a version other than the current one broadcast.py:adopt_version makes it current and retargets everything. bump_fee ends with a conditional UPDATE … WHERE status='pending' AND current_txid=<replaced>; zero rows is a logged conflict, never an overwrite. broadcast_at is the first broadcast and is never rewritten (the reconciler's abandon clock measures from it); last_broadcast_at is what a bump updates and should_bump reads. Confirmation handlers key off immutable ids (round_id/user_id, withdrawal_id), never the txid, which changes under them.

Payouts retry, and are guarded against paying twice. Every tick re-examines a paying_out round: _retry_payout_if_due throttles to one attempt per 60s using the latest payout_failed audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in _trigger_payout writes one, so /admin shows why a round is stuck. Before building, _trigger_payout refuses if a building/pending payout already exists for the round, and _reserved_payout_outpoints excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.

"At most one active round" is a DB invariant, not a convention: ix_rounds_single_active (unique index over the constant (1), restricted to the active statuses) makes a concurrent second insert fail cleanly, and open_new_round_if_needed recovers by adopting the winner's round (max 3 attempts).

Frontends

Two static SPAs served directly by FastAPI (main.py mounts app/static/ and adds routes for /admin, /guida, /report-bug) — no build step, no framework, no bundler. Cache-Control: no-store is set on the four HTML routes only; the JS/CSS under the StaticFiles mount get ETag/Last-Modified and no Cache-Control.

  • / — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from GET /qr/{address}, Bet, Prelievo, Profilo — account info + self-service password change via POST /users/me/change-password), above a persistent round-status card and the chain-status bar with the language switcher.
  • /admin — gated by a token screen (not a login: just X-Admin-Token vs ADMIN_TOKEN), then six sections each backed by its own /admin/* endpoint: Parametri (RoundConfig + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log, Bug report (triage open → read → resolved, audit-logged bug_report_status_changed); plus a live Electrum/tip-height pill. Deliberately not linked from / in either direction.
  • /report-bug — standalone page (no navbar, own language switcher), reachable logged-in or logged-out: POST /bug-reports stores the report with the submitter attached when there is one, GET /bug-reports/mine is the reporter-side status view for the logged-in case, and /admin's Bug report section is the triage end.

Both talk to the same JSON API; there's no admin/user API split beyond require_admin.

Internationalization (/ and /report-bug)

app/static/i18n.js holds every user-facing string of / and /report-bug in 7 languages (en, it, es, fr, de, ru, zh) as one flat TRANSLATIONS table — no build step, no fetch. / loads it before app.js; /report-bug loads it before its own inline script — either way t() is always available by the time it's called. Language: localStorage.plm_lang → navigator.language → en, shared across both pages since they read/write the same localStorage key. On / the switcher sits in the chain-bar, not the navbar, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. /report-bug has no navbar at all, so its switcher is just a top-right bar of its own.

  • Static markup is translated by attribute (data-i18n, plus -html, -placeholder, -title, -aria-label, -alt) via applyStaticTranslations(root?); anything rendered from server data uses t() directly (app.js's onLanguageChange(), report-bug.html's own inline equivalent) and is re-rendered on a language switch. An element belongs to one camp or the other, never both, or the two mechanisms overwrite each other — that's why #bet-btn has no data-i18n: its label carries the configurable bet amount, so renderBetButton() owns it.
  • Every language must have exactly the same key set. There is no fallback beyond en; a missing key renders as the raw key string.
  • /report-bug's bugReport.englishNotice string is itself translated into all 7 languages — it just always says, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages).
  • /admin is intentionally untranslated (operator-facing, Italian), as is /guida.

API error contract (app/api/errors.py) — the API is single-language by design. Failures answer with a structured detail: {"code", "message", "params"}, where message is English for non-dashboard consumers and code is what the frontend maps to error.<code> in i18n.js (falling back to message for an unknown code). BetError/WithdrawalError subclass ApiError and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (internal_error), so clients rarely special-case unexpected errors (the exceptions — require_admin's 403/404s, the SSE 503, FastAPI's own 401/404/405/422 — still carry a bare-string or list detail; see docs/api.md), and the exception text stays in logs/app.log. Adding a user-facing error: give it a code, add error.<code> to all 7 languages, and pass values through params (amounts as *_sats — the frontend derives a *_plm sibling) instead of baking them into English text.

Non-obvious domain decisions

Explicit design choices, not derivable from any single file — respect them:

  • Keys are generated and held server-side: this is custodial. The user controls nothing until they withdraw.
  • The deposit address is the winnings address — there is no separate "winner address".
  • 1 confirmation for every transaction the platform builds (bets, withdrawals, payouts, and the outputs they create). Deposits (deposit_confirmations, 6) and coinbase outputs (120) are the explicit exceptions of B-82 — don't add other thresholds without an explicit decision.
  • The draw algorithm is a replaceable component, not the final design — don't architect around its current form.
  • GET /admin/users/{id}/privkey exporting a raw WIF is intentional, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes admin_privkey_accessed — don't remove that logging.
  • Usernames are case-insensitive (B-57): one namespace, enforced by a unique index on lower(username) (app/db/models.py) and matched with func.lower(...) on both register and login. The name is still stored as typed — that's what /admin and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
  • Argon2 hashing means no password recovery, only reset: POST /admin/users/{id}/reset-password sets a new random password, returns it once for the operator to relay, and logs admin_password_reset. No self-service reset exists (no email is ever collected); a logged-in user can only change their password by supplying the current one.
  • RBF bumps are paid by whoever's output the tx reserves for it — the user's change for bets/withdrawals, the bump reserve (paid to the reserve address) for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only that one output shrinks, or is dropped into the fee when it would fall under dust (bump_fee, B-87).

Known gaps / TODO

Accepted by design — distinct from the audit findings above (all fixed), which are not duplicated here.

  • The drawing wait is unbounded by design (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past _DRAW_STALL_THRESHOLD_SECONDS it logs progress and writes a draw_stalled audit entry, and GET /rounds/current's draw_waiting_since surfaces it live (B-36).
  • RBF handles one shape only: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve (B-79). No extra-input fallback and no CPFP, and none would help the case that used to hurt (a transaction that already spends everything the sender has) — which is why build_signed_transaction guarantees a change output of at least DUST_LIMIT_SATS (B-62) and every payout carries the reserve. Since B-87 a bump that would leave that output under dust drops it and gives its whole value to the fee (the replacement is then the last possible one); a bump with no output, or one whose output cannot even pay BIP125's minimum increase, raises RbfError. Every such failure — and max_fee_rate and a replacement the network refuses — is written by RbfBumper._record_failure as one rbf_bump_failed audit row per (current txid, reason). What stays accepted: a transaction that has exhausted its change or reserve and still does not confirm waits, visibly, and is never abandoned while a server knows it.
  • Withdrawal and RBF bump are unit-tested but never live-broadcast (failure and rollback branches included — still not a real network).
  • No user-facing history of rounds or transactions. GET /users/me/last-round-result covers exactly one case (the reveal backstop above) and GET /bug-reports/mine one more (the reporter's own reports). Admin has /admin/rounds, /admin/pending-transactions, /admin/audit-log; a user has no equivalent — a failed withdrawal leaves a failed row they can never see, which argues for closing this.
  • Admin auth is one shared bearer token (ADMIN_TOKEN) with no per-admin identity: audit_log records what changed (config edits as config_updated, with before/after) but never who did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
  • No rate limiting on bet, withdrawal, admin or SSE. Login has a per-username + per-IP failure throttle and registration a per-IP quota of 5 accounts/hour (app/auth/rate_limit.py: RateLimiter for failed guesses at a secret, RollingQuota for "how many of these may one source create" — B-33, B-58); everything else is unlimited.
  • /guida is a placeholder (app/static/guida.html) — the link works, the content is "coming soon". /report-bug is not: it is fully implemented and translated, with admin triage (see Frontends).
  • No integration tests against a live Electrum connection. tests/integration/ is empty; live verification has all been manual (scripts/electrum_smoke_test.py, ad hoc scripts, real mainnet txs).
  • Single-process assumptions: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is not in this category — it's a DB index.