A reorganisation was neither recorded nor acted upon, and a competing header at the tip's
own height was ignored for as long as the tip stood, leaving the listener on an orphan.
- A header at the height we hold with a different hash is followed through the same
fork-point search as any other reorg (ELC-09) instead of being ignored; it still has to
bring valid work and a connected chain.
- ElectrumListener._record_reorg writes `chain_reorg_detected` (depth, fork height, old and
new tip height and hash) before the window moves, so a failed write ends the session and
the next server's announcement of the same fork is recorded then. Depth 1 is a
transaction of its own; depth >= 2 also pauses the lottery with paused_reason 'reorg'
and writes `lottery_paused` in the same transaction (begin_immediate first).
- Docs: ELC-09/22/23/24, AUD-03/11/38, DAT-30/69, OPS-30, RND-04, ADR-26/52 lose their
B-85 "da implementare" lines.
Tests changed because the spec changed: test_apply_header_ignores_a_competing_header_at_
the_current_tip_height encoded the B-64 behaviour that ELC-09 now reverses; it is replaced
by test_a_competing_header_at_the_tip_height_is_a_reorg_to_follow and the depth tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A header only had to meet the target it declared for itself, so a fabricated one with
`bits = 0x207fffff` passed half the time, a jump of several blocks was accepted without
being linked to the tip (a fake header at a huge height locked `tip_height` and froze every
draw), and an ordinary one-block reorganisation closed the session on every server.
- draw.py: MAINNET_POW_LIMIT (00000000ffff...ffff); header_meets_its_own_target also
refuses a claimed target easier than POW_LIMIT.
- ElectrumListener._apply_header is async and takes the active client. A single block must
chain by prev_hash; a jump downloads the headers in between (blockchain.block.headers,
at most 2016 per request, ElectrumClient.get_block_headers) and each must carry valid
work and chain, otherwise HeaderValidationError ends the session.
- The listener keeps the last 10 headers (`_window`), built from the process's first header
and its 9 predecessors (which must chain into it). A header that does not join the tip is
walked back to its fork point inside the window and the server's branch is followed
instead of ending the session; a fork older than the window, or a branch that does not
end at the announced header, still ends it. The audit row and the pause for deep reorgs
are B-85; a competing header at the tip's own height is still ignored until then.
- _header_at also serves older heights from the window.
Tests changed because the spec changed: the mainnet powLimit cannot be mined in a test, so
tests/unit/conftest.py swaps in a regtest limit at import time (the new tests put the real
one back); the _apply_header tests are async; the two _run_once tests serve the first
header's 9 predecessors; test_apply_header_skips_linkage_check_across_a_height_gap is
replaced by the jump tests, since that behaviour is exactly what the finding removed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
poll_once marked a transaction confirmed on one server's `height > 0`, and
reinstate_reappeared_utxos restored a coin written off as spent outside the platform on
the active server's word alone, so a hostile active server could report a
never-relayed payout as confirmed (winner unpaid, round closed) or put a spent coin
back into a balance.
- electrum/merkle.py: pure merkle-branch verification against a block header.
- ElectrumListener.confirmation_proven(txid, height): the *other* servers are asked for
blockchain.transaction.get_merkle; a strict majority of at least the quorum minimum
must return a proof that recomputes to the merkle root of that block's header (block
height and position checked too). The header is the tip the listener holds, or for an
older block the one a majority of the quorum agrees on and that meets its own target;
a height above our tip cannot be proven. Quorum rules are _corroborate_majority's.
- poll_once takes the listener as `network`: the active server's history only nominates
a candidate (txid, height); the row confirms only when the proof holds, and stays
pending otherwise. With no network nothing confirms. ConfirmationPoller wires it.
- The reconciler's late-confirmation check of failed rows (B-78) now puts each claimed
(txid, height) to the same proof instead of counting histories.
- refresh_user splits reinstatement like a credit: find_reinstate_candidates (read-only;
same outpoint, recorded amount, confirmed) -> corroborate_utxo_reinstatement (same
quorum as a credit) -> reinstate_reappeared_utxos writes only what was corroborated,
rechecking the mark under the lock. Nothing is committed in the read phase any more.
- Broadcast fan-out, the third part of the finding, shipped with B-76.
Tests changed because the spec changed: every poll_once call now passes a network that
proves (FakeNetwork) — the confirmation tests are otherwise unchanged; the failed-row
"quorum does not confirm" test now asserts the claim goes to the proof.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
reconcile_once abandoned a row when the *active* server's history lacked its txid:
after 120s for `building` (which a restart onto a different server makes likely),
after 6h for `pending`, and all at once after a forward clock jump. It re-checked the
status but not the txid, so it raced the bumper, and a `failed` row was never looked at
again.
- The reconciler asks every server (ElectrumListener.get_histories, active + others) for
the history of the row's address and looks for every txid of the row. Present on any
server -> promoted (adopting the version found). Absent is only "no server of the
quorum knows it" and needs the active server plus the quorum minimum of the others to
have answered; even then nothing is released without proof:
(a) an input spent in a block by a transaction that is none of the row's versions,
agreed by a strict majority of a quorum of the other servers (confirmed_spender), or
(b) a rebroadcast of the latest raw tx to every server (rebroadcast_everywhere)
refused by all of them, explicitly, for missing/spent inputs.
Accepted or "already known" by any server means alive; a fee refusal is not proof
(`building` -> `pending` for the bumper, `pending` untouched); too few answers or
anything else leaves the row alone. failure_reason names the proof.
- The write re-reads status *and* current_txid under begin_immediate (TX-26).
- pending_transactions.failed_at (set by _abandon and _release_failed_withdrawal) starts
a 14-day watch: a failed row that a majority of the other servers (at least the quorum
minimum) list in a block becomes `confirmed`, writes pending_tx_resurrected once and
pauses the lottery with paused_reason `resurrected_tx`; linked rows are not corrected.
The confirmation is by quorum of histories for now; merkle proofs come with B-83.
- round_config.paused_reason (migration b7d2e41c9a05, with the failed_at one): `operator`
from POST /admin/pause (which accepts an optional `note` and never overwrites an
automatic reason), cleared by resume, which logs the reason it lifted; exposed on
/admin/config and GET /rounds/current. rounds/pause.py:pause_automatically is the
system's way in.
- PendingTransactionReconciler takes the listener as `network`; with none it can promote
but never abandon.
Tests changed because the spec changed: the three reconciler tests that expect an
abandonment (and the one in test_tx_versions) now supply a network that proves the
transaction is gone.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The poller and the reconciler matched current_txid only. If an earlier version of
a bumped transaction confirmed it was never seen, and after 6h the row was abandoned:
a confirmed bet's participant deleted, a sent withdrawal marked failed, a paid payout
rebuilt. bump_fee also broadcast in phase 2 and persisted in phase 3 unconditionally,
so a crash or a `database is locked` in between left a transaction on the network that
the DB did not know, and an abandon or a confirmation in the meantime was overwritten.
- New table pending_transaction_txids (migration a3f5c2d9b7e1; txid unique, index on
the owning row; existing rows backfilled with current_txid + raw and the txid their
last bump replaced, raw_hex empty). Written with the row by place_bet,
request_withdrawal and _trigger_payout; removed with a bet row that a verified
refusal deletes (DAT-38).
- bump_fee in four phases: read, build and sign, commit the replacement as a candidate
version, broadcast (verdict of B-76, fanned out when accepted; an explicit refusal
raises RbfError and leaves the row), then switch the row over with
`UPDATE ... WHERE status='pending' AND current_txid=<replaced>`. Zero rows matched is
a logged conflict: nothing is overwritten and the candidate stays recorded. An
uncertain broadcast adopts the replacement. A retry rebuilds the same candidate
without tripping the unique txid.
- poll_once and reconcile_once look for every txid of a row. When the version found is
not the current one, broadcast.adopt_version makes it current (raw tx included) and
retargets spent_txid, bet_txid, withdrawals.txid and payout_txid as a bump does,
before the confirmation handler runs. _release_inputs frees inputs claimed by any
version of the row.
- RbfBumper takes the listener as `network`.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
place_bet and request_withdrawal released everything on any exception from the
broadcast, including the 15s timeout and a connection dropped after the write. The
tx may have propagated: a bet's sats landed in the pool with no participant, a
withdrawal left while its row said failed and its coins were spendable again.
- client: ElectrumRpcError (subclass of ElectrumError) is raised only for a reply
carrying a JSON-RPC `error`; timeouts, drops and write errors stay plain
ElectrumError.
- tx/outcome.py:broadcast_with_verdict sorts a broadcast into accepted / refused /
uncertain. A refusal is verified first: ElectrumListener.transaction_known asks
blockchain.transaction.get of the active server and every other one (10s each) —
any server returning the tx means accepted; the active server plus
MIN_QUORUM_RESPONSES others all denying it means refused (ALLOW_NO_QUORUM keeps
the reduced rule); too few answers is uncertain. "already known" wording is only a
shortcut that skips the check. With no network to ask, a refusal is uncertain.
- place_bet / request_withdrawal / _trigger_payout use it: only a verified refusal
releases; uncertain leaves the rows building for the reconciler, publishes SSE and
POST /bets and POST /withdrawals answer 202 with status building; "already known"
promotes a payout (payout_sent) like any accepted broadcast.
- An accepted transaction is fanned out to every other server
(ElectrumListener.fan_out_transaction, best effort, off the caller's path). The RBF
replacement in bump_fee is left for B-77, which rewrites that function.
Tests changed because the spec changed: the existing "refused broadcast" tests raised
a bare RuntimeError (now an uncertain outcome) and are made explicit refusals
(rpc_error) with a network that denies the tx; direct route calls pass a Response;
FakePayoutListener gains the network surface.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
An empty ELECTRUM_FALLBACK_SERVERS (the .env.example default) silently made every
corroboration pass, and with fallbacks the majority was taken over whoever
answered, so one reachable fallback decided alone. TLS certificates were never
checked.
- _corroborate_majority passes only with at least 2 answers (MIN_QUORUM_RESPONSES)
and a strict majority of the answers; no other server configured, or fewer than
2 answering, fails. ALLOW_NO_QUORUM=true keeps the old reduced rule.
- config.validate_quorum_servers, called from the lifespan after parse_endpoints,
refuses to start with fewer than 2 fallback servers unless ALLOW_NO_QUORUM=true
(which logs a warning at every start).
- Optional pinning: `host:port[:mode]:pin=<sha256>` per fallback and
ELECTRUM_CERT_PIN for the primary; ElectrumClient compares the SHA-256 of the
DER certificate after the TLS handshake and a mismatch fails like an unreachable
server. A pin on a plaintext entry is a startup error.
- .env.example now ships the four bootstrap servers and documents both new
variables; docs/setup.md, spec, storico and BUGS.md updated.
- Tests changed because the spec changed: the two "no other servers configured ->
True" corroboration tests now assert that outcome only under allow_no_quorum.
Note: a dev .env with fewer than 2 fallback servers will now refuse to start.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history
grew forever, other rounds' money and stray coins mixed with a round's own, and
the payout picked from everything it held.
- hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2
(m/84'/746'/0'/2/<round_id>); no argument is the reserve address 1/0.
derive_round_pool_key checks the key against the address stored on the round,
so a payout can never be signed for money its key does not control.
- rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with
the reserve address since that is where their bets went; add_column, not a
batch alter, so the expression indexes stay): written in the transaction that
inserts the round, once its id exists.
- place_bet pays round.pool_address; the payout reads, signs and takes change at
the round's own address; own_address_for and bump_fee's _signing_context follow
the round (confirmation poller and reconciler pass round_id).
- Withdrawals to any round pool address, the reserve address or fee_address are
refused (withdrawal_to_platform_address, 400, all seven languages).
- GET /rounds/current and GET /admin/rounds expose pool_address.
- RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every
confirmed coin at the round's address, not only the participants' outpoints
(participants carry no bet_vout yet).
- tests/unit/conftest.py: every test now gets a throwaway master key. Opening a
round derives an address, and tests that merely opened a round were silently
reading the real key from the local .env.
- Existing tests changed because the spec changed: payout fixtures give their
round a pool_address; the reconciler's address-cache test uses two payouts of
one round (rows of different rounds no longer share an address);
own_address_for now takes the round id.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
batch_alter_table on SQLite rebuilds the table and silently drops
ix_rounds_single_active and ix_users_username_lower, autogenerate never sees them,
and no test ran the migrations (the others build the schema with create_all).
tests/unit/test_migrations.py runs `alembic upgrade head` on an empty file DB in a
subprocess and asserts both indexes exist with their definition (read from
sqlite_master) and that a second active round and a case-variant username are
refused. Verified to fail when a batch_alter_table on rounds is added to the last
migration. The current chain already preserves both indexes, so no migration changes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A reply line over asyncio's default 64 KiB StreamReader limit raised out of
_read_loop and closed the only connection; the pool address's get_history passes
that after ~800 transactions, and dust sent to any public address does it on
purpose.
- ElectrumClient opens the stream with a 16 MiB limit and frames lines itself:
an oversized line is discarded up to its newline, a non-JSON line is dropped,
and the request whose id is readable in the first/last 256 bytes fails at once;
the connection stays up either way.
- Coins below DUST_LIMIT_SATS are never offered as credit candidates, never
credited, and select_utxos never selects them.
- Spec (ELC-20, DEP-01, DAT-10, ARC), storico, BUGS.md and test counts updated.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Add docs/README.md as the index of all documentation.
- Split the 52 DEC entries of decisioni-e-limiti.md into docs/adr/ADR-NN
files (same numbers), add ADR-53 (audit log), TEMPLATE.md and an ADR index
with a feature -> ADR table.
- Rename decisioni-e-limiti.md to limiti-noti.md (LIM only) and rewrite all
DEC-nn references as links to the ADRs.
- Document the docs-first / SSOT / ADR rules in processo-di-modifica.md and
map the docs in CLAUDE.md and README.md.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
pysqlite's legacy mode emitted BEGIN only at the first DML, so every read
before it ran outside any transaction and a lock wait surfaced as "database is
locked" at an arbitrary later statement; place_bet also mistook that error for
a write-snapshot conflict, which SQLite cannot produce.
- _register_sqlite_pragmas: synchronous=FULL, isolation_level=None and a
`begin` event that emits BEGIN / BEGIN IMMEDIATE.
- begin_immediate(session): takes the write lock before the first read; called
first thing in place_bet, request_withdrawal, the scheduler transitions,
_trigger_payout, the confirmation poller, the reconciler, bump_fee's persist
phase, deposit crediting / external-spend marking / reinstating, and the
derivation-index allocation at registration.
- A lock not granted before a broadcast answers service_busy (503, all seven
languages), nothing built or sent; after a broadcast nothing is undone, the
rows stay `building` for the reconciler.
- Spec, storico, BUGS.md and CLAUDE.md updated; 37 new tests on a real file DB.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Finishes what 88221b0 started: sicurezza.md, audit-log.md and storico-bug.md
now describe the target behaviour too, the operator guides are corrected
against today's code, and api.md gains the missing /health/ready and
/admin/electrum sections plus every new error code in its catalog.
Harmonised names and owners across documents (the `resurrected_tx` pause
reason, the reconciler as the one that watches failed rows, the full
`winner_drawn` payload, a reason for an unanswered pre-payout pool check,
one corroboration-failure row per hash instead of one per tick).
Adds B-98 (coin selection stops at the first covering UTXO and refuses a
payable bet), tightens RND-40 to check both 1 and 400 participants and to
guard PUT /admin/config, and gives a stalled `closing` round a signal
(TX-29, closing_stalled).
Records three decisions confirmed on 2026-09-29: the draw's server_seed is
stored Fernet-encrypted until revealed (SEC-56, DAT-64), the chosen abuse
and key-check thresholds (SEC-49, SEC-54, SEC-55), and a broadcast
rejection is verified against every server before any coin is released,
so matching "already known" text is a shortcut, not a safety condition
(TX-04).
Spec only; no code changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fourth full audit, run against the docs/ specification instead of the code
alone, found logical flaws rooted in PLM's small hashrate (~22 TH/s): a
minable draw, no distinction between "never broadcast" and "unknown if
broadcast", an Electrum client that degrades as the pool address's history
grows, and 1-confirmation deposits on a chain where a reorg is rentable.
The spec now describes the target behaviour for every finding, marked
*Stato:* da implementare (B-nn) with an *Oggi:* line for what the code does
now; docs/piano-correzioni.md records the design decisions and the
implementation order. No code changed — this is spec-only, per B-nn
convention when a bug reveals the spec itself was incomplete.
Also documents, as an accepted-but-open risk (LIM-24), that the optional
jackpot cap (max_round_pool_sats) isn't tied to the cost of a 51% attack —
deferred, not decided.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The two Mermaid diagrams under flowchart/ described the intended flow but
had drifted from the code and carried no link back to it. docs/ now holds
the specification instead: 15 Italian documents (flows, architecture, data
model, API, security, audit log, operations, decisions and limits, bug
history) with 472 numbered requirements, each pointing at the function
that implements it and the test that covers it. docs/processo-di-modifica.md
defines the ID convention and the spec -> code -> test change workflow, and
CLAUDE.md now names docs/ as authoritative.
Writing the spec from the code also corrected stale claims in CLAUDE.md
(B-41's get_history check, BUGS.md still tracking B-71/B-72, Cache-Control
scope, the error contract's bare-string exceptions) and in the operator
guides (payouts do retry, only `drawing` fails to resume, rounds do not
open without fee_address). The B-70 doc test now pins the prose in
docs/scommesse-e-prelievi.md instead of the deleted diagram.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fee_address has no column default, because an operator has to supply their own —
and the payout pays the 30% commission to it, so build_payout_transaction cannot
even be built without one. A fresh instance nonetheless opened rounds happily:
each took bets, confirmed them, and only then discovered it was unpayable,
wedging in "paying_out" and retrying every 60s with money already in the pool.
One manual recovery per round, until somebody noticed.
open_new_round_if_needed now checks rounds_can_open(config) alongside `paused`:
no payout address, no round. Nothing has moved yet at that point, which is the
whole difference. Same scope as pausing — a round already in progress still
closes, draws and pays out, since clearing the address mid-round is exactly the
operator slip that must not strand a live round.
Surfaced rather than silent, in the two places that matter: lottery_configured on
GET /rounds/current, which makes / show a *different* banner from the maintenance
one (telling a player "come back later" would be false — nothing is coming until
setup finishes), and a warning at the top of /admin's Parametri card, the one
screen that can fix it. rounds_can_open is where any future
would-make-a-round-unpayable prerequisite belongs, instead of being discovered at
payout time.
The test churn is the finding restated: 26 tests expected a round to open on an
instance with no payout address. Their fixtures now seed one, so each goes back to
testing what it says — several would otherwise have passed for the wrong reason,
returning None because of the missing address rather than because of the cooldown
or pause under test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The max-amount checkbox sends amount_sats == the whole confirmed balance, so
change came out at 0, the change output was dropped, and the transaction had a
single output. bump_fee has nothing to shrink there: it raised RbfError every
30s until the reconciler abandoned the row six hours later. The RBF
single-change-output limitation was a documented gap, but the UI made it the
*default* withdrawal path.
The extra-input fallback would not have helped this case: a transaction moving
the entire balance already spends every UTXO the sender has. So the fix is at
build time — build_signed_transaction never produces a change output below
DUST_LIMIT_SATS, and never folds it into the fee either:
- withdrawals pass reduce_amount_to_keep_change=True and move a dust limit less.
The fee already comes out of the withdrawn amount by design, so this is the
same rule applied a little harder, and Withdrawal.amount_requested_sats vs
amount_sent_sats already existed to record the difference.
- bets don't: the bet is a fixed price that can't be quietly reduced. A balance
exactly equal to the bet is refused with balance_leaves_no_change (translated
into all 7 languages, carrying required_extra_sats), which turns "a user's
balance must never exactly equal the bet" from a documented assumption into an
enforced one — and stops an unbumpable bet from holding a round open until the
reconciler gives up on it.
bump_fee's no-change guard stays: a single-output tx broadcast before this
change can still be pending across the deploy, and it must fail loudly rather
than start shrinking a recipient's output. Its test now hand-builds that shape,
precisely because the builder no longer will.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
round_duration_seconds was read live on every scheduler tick and every bet
check, with the deadline computed as opened_at + duration. Lowering it from 600
to 60 while a round was 300s in closed that round instantly; raising it moved
the closes_at clients were already counting down to. round_cooldown_seconds had
the same property for the gap after a close. B-11 fixed this class of problem
for the advertised jackpot; the timing fields were left live.
Round now carries duration_seconds and cooldown_seconds, set from the config
when it opens. round_deadline() is the single place the deadline is computed —
the scheduler, place_bet's two checks and /rounds/current's closes_at all go
through it — and the cooldown is read off the round that just closed, so the gap
a round announced is the gap that's honoured. The config row becomes what the
*next* round opens with.
The migration backfills from the live config rather than leaving the column
defaults: an instance running 300s rounds would otherwise see the round
currently in progress jump to 600s the moment this lands, which is precisely the
retroactive change being fixed. Verified against a scratch DB with a non-default
config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other admin mutation — config edit, pause/resume, privkey export, password
reset — leaves a trace; this one could silently mark a report resolved. With one
shared ADMIN_TOKEN and no per-admin identity, the audit log is the only
accountability there is.
bug_report_status_changed records the report id and the before/after status, and
carries the report's author as user_id so the entry is traceable from either
side. Nothing is written when the status doesn't actually change, matching
config_updated: an edit that changes nothing isn't an event, and noise hides the
real changes.
Also documents the new event in docs/guida-admin.md's audit table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md declares the server always runs via Docker (dev and prod
alike) with no supported bare-uvicorn workflow, but README's Quick
start and docs/running-the-server.md's "Locale / venv" section still
documented running uvicorn directly — a leftover from before that
policy was adopted. Rewrite both to a single Docker-only path and
update CLAUDE.md's own note about it.
Verified docker compose run --rm app python scripts/generate_master_key.py
against a real build/run to confirm the Quick start's Docker commands
actually work as documented.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
They enumerate the entire API surface, admin endpoints included, to
anyone who requests them. Gate them behind a new ENABLE_API_DOCS
setting (off by default) and update README/docs and BUGS.md/CLAUDE.md
open-bug counts accordingly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A dropped connection used to hang the whole platform permanently, and three
defects composed to do it (BUGS.md B-01):
The read loop's death was invisible. When the socket closed, _read_loop broke out
and finished, but _run_once was blocked on gather() over two notification
consumers waiting on queues nobody would ever fill again — it never returned and
never raised, so the reconnect-with-backoff logic was unreachable.
client.wait_closed() now resolves when the loop ends for any reason, and
_run_once races it against the consumers and a keepalive with
asyncio.wait(FIRST_COMPLETED).
Nothing had a timeout. request() registered a future, wrote to a half-closed
socket (drain() often doesn't raise) and awaited a reply that would never come.
That hung a POST /bets *while holding the per-user lock*, and could stop the
confirmation poller for good. Every request is now bounded at 15s, and a timeout
tears the connection down rather than leaving a server that owes us a reply in
rotation.
There was no keepalive, so on a quiet instance the normal way this connection
dies is an idle-timeout drop by the server (~10 minutes for many). A server.ping
every 60s makes that observable within a minute.
listener.client is also cleared before reconnecting, so callers stop treating a
dead connection as live.
On top of the finding, the listener now rotates over a list of servers:
ELECTRUM_FALLBACK_SERVERS holds comma-separated host:port[:notls] extras, tried
after the primary. Everything the platform does goes through this one connection
— deposit credits, broadcasts, confirmations, the chain tip the draw waits on —
which made a single hardcoded server its biggest point of failure. A failed or
dropped session moves to the next server immediately and only sleeps on the
backoff once every server has had a turn, so one dead server costs one attempt
instead of an outage, while a genuinely offline network still backs off. A
malformed entry fails at startup, not during the outage when the fallback is what
you need.
Also fixes B-19: header handling refuses a height below the current tip and
applies height and hex together, since _wait_for_next_block waits for
tip_height > tip_at_close (a regression silently added a block to the draw's
wait) and that hex is the draw's entropy source, so a mismatched pair would be
worse than a stale one.
Verified in the live deployment: the log shows the endpoint list, then "Electrum
connected to santantonio.sytes.net:50002", and the connection holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
CLAUDE.md: bumped the stale test count (54 -> 76), added "Balance display"
and "Real-time updates (SSE)" sections, and rewrote the DRAW section's
frontend-reveal paragraph to describe the actual current behavior (dual
status/result boxes gated by user_played, closes_at-anchored reveal delay,
localStorage persistence, the last-round-result backstop) instead of the
older single-box design. Refined the "no history endpoints" known gap now
that GET /users/me/last-round-result exists (still not general history).
README.md: same test count fix, expanded coverage list.
docs/: fixed a pre-existing broken link in setup.md (admin-guide.md ->
guida-admin.md), added a note in running-the-server.md that editing the
bind-mounted Caddyfile needs an explicit `docker compose restart caddy`
(discovered while adding the SSE Caddy config in a prior change), and
rewrote guida-utente.md's draw/reveal section plus the balance/withdrawal
sections to match what the UI actually does now. guida-admin.md was
reviewed but needed no changes.
app/static/style.css: dropped `.toast.info`, dead since the toast-based
loss notification it styled was replaced by the persistent result box.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
decrypt_master_key.py: prints the existing master xprv after an explicit
confirmation prompt, for disaster-recovery backups. Falls back to
./data/keys/master.xprv.enc (the docker-compose.yml bind-mount path) when
.env's configured MASTER_KEY_PATH doesn't exist locally.
encrypt_master_key.py: the reverse direction — takes an externally-generated
xprv (e.g. created offline/air-gapped) via a hidden getpass prompt, validates
it parses as a private extended key, and encrypts it with the same Fernet
scheme generate_master_key.py uses. Refuses to overwrite an existing key file
unless --overwrite is passed.
Neither script is reachable via any API endpoint or the admin panel, by
design — this is the one secret the entire custodial wallet derives from.
Documented in docs/setup.md (new "Recuperare o portare una xprv esistente"
section) and CLAUDE.md's Commands block.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
embit's address_to_scriptpubkey only knows Bitcoin's network prefixes, not
PLM's — so a legacy P2PKH address (prefix 55, "P...") silently produces a
None scriptpubkey instead of a clear rejection, and P2SH only works by
coincidence. Until the address decoding is fixed to use PLM's own network
prefixes, surface the current limitation to users in the withdrawal form
and the user guide instead of letting them lose funds to a broken address.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md and the user/admin guides only mentioned "a confirmed block" for
the draw, leaving the actual end-to-end timing (why it can take several
minutes after the countdown hits zero) unclear. Spell out the three distinct
confirmations in sequence — last pending bet, draw block, payout tx — and
the resulting best/worst-case wall-clock estimates.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
CLAUDE.md's DRAW architecture bullet now explains draw_animation_seconds
and its decoupling from the real block-wait timing. guida-utente.md
gets a new "Estrazione del vincitore" section describing what a player
sees and when. guida-admin.md's Parametri table and hardcoded-defaults
note include the new field.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
guida-admin.md now covers all five dashboard sections instead of just
Parametri/Utenti, plus an explanation of who actually pays an RBF fee
bump (the transaction's own sender/pool, never the fixed recipient
amount) placed right next to the timeout field it governs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md and docs/guida-admin.md now describe RoundConfig as the sole
source of truth for business parameters, with no env var counterpart —
defaults live as hardcoded model column defaults, not app/config.py.
guida-admin.md documents the four new dashboard sections (Round,
Transazioni pendenti, Audit log alongside Parametri/Utenti). setup.md
points readers to the admin panel instead of .env for those values.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
open_new_round_if_needed now withholds opening the next round until
ROUND_COOLDOWN_SECONDS (default 30) have passed since the previous
round's closed_at, returning None in that window instead of a Round.
Without this, the next round opened within one scheduler tick (~5s) of
the previous payout confirming — not enough time for a player to
notice the round they were in actually resolved.
Callers updated: the scheduler treats None as "nothing to do this
tick", and place_bet raises a "try again shortly" BetError instead of
crashing on a None round.
Not in the original flowchart — a deliberate UX addition on top of it,
documented as such in CLAUDE.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four standalone Markdown docs instead of growing CLAUDE.md further:
setup.md (one-time secrets/master-key/migrations), running-the-server.md
(local venv vs Docker+Caddy, dev self-signed vs production domain),
guida-utente.md (dashboard: deposit+QR, bet, withdrawal, round timer/
jackpot) and guida-admin.md (the /admin panel and its API equivalent).
Written in Italian per explicit request, unlike the rest of the
repository's English-only docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>