The pool normally holds exactly the round's bet outputs and the payout spent them all for
exactly the pool amount, so it had no change output: bump_fee could never accelerate it,
and a payout stuck in a mempool (never abandoned while a server knows it) kept the round in
`paying_out` and the whole lottery stopped.
- build_payout_transaction always has three outputs: winner, fee address, and a bump reserve
of max(DUST_LIMIT_SATS, estimate_vsize(inputs, 3) * 10 sat/vB) paid to the reserve address
m/84'/746'/0'/1/0. The reserve comes out of the platform's 30%, the network fee still out
of the winner's 70%. Input value beyond winner share + commission (only rounds opened
before the per-round pool address) is folded into the reserve output if >= dust, left to
the network fee otherwise. `change_address` is replaced by `reserve_address`.
- `rounds.bump_reserve_sats` (Alembic e5a91c3d7b26, plain add_column) is written with the
payout intent before the broadcast; /admin/rounds returns it.
- bump_fee takes the increase from the reserve output for a payout
(_find_reducible_output; a payout already in flight with a change to the pool still falls
back to that), never from the winner or fee-address outputs.
- payout_config_problem / rounds_can_open (RND-40): a bet amount and fee rate under which a
round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants could not cover the network fee,
the reserve and dust outputs opens no round (lottery_configured=false); PUT /admin/config
refuses such a bet amount or fee rate with 422 config_not_payable, judged on the resulting
values, since the payout reads the fee rate live.
Ambiguity resolved: the reserve output and any excess input are one output (RND-23 says one
output to the reserve address), and bump_reserve_sats records that output's initial value.
DAT-22's "abandon clears all amounts" stays with B-96.
Tests changed because the spec changed: the four test_payout_builder tests asserted the old
two/three-output shape with change to the pool and the fee address receiving the whole 30%;
the whitespace fee_address test now spells out the amounts rounds_can_open reads. New: reserve
sizing, payout with 1 and with 400 participants (inputs = outputs + fee to the satoshi),
exact commission/reserve/dust boundary, excess folded or left to the fee, a real payout bumped
out of its reserve with the other outputs byte-identical and bumped five times, the old
reserve-less shape still raising RbfError, the scheduler output addresses and recorded
amounts, payability boundaries found independently by bisection, no round / unconfigured flag
for an unpayable config, a round in progress surviving it, PUT /admin/config refusals and the
migration column. Mutation-checked: dropping the reserve lookup in bump_fee and the payability
check in rounds_can_open each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`_tick` skipped `drawing`: the wait for the draw block lived only inside the
`_close_and_draw` call that entered it, so any exception (a locked database writing an
audit row, a malformed header) or a restart left the round in `drawing` for ever with the
pool inside and no new round able to open.
Since B-73 everything the draw needs is committed on entering `drawing` (encrypted secret,
ordered list with `draw_position`, `participants_hash`, `draw_height`, `draw_confirmations`),
so `_tick` now calls `_complete_draw` for a round in `drawing`. `_complete_draw` rereads that
state, is idempotent (a round no longer `drawing`, or with a winner, is left alone; the
winner write is re-checked under BEGIN IMMEDIATE) and runs inside the single scheduler loop,
so the inline call from `_close_and_draw` and the tick can never run concurrently.
Tests (test_draw_proof.py): an exception in the quorum query, then a tick, completes with the
same height, list, fingerprint and the winner the public proof recomputes; a brand-new
scheduler (restart) resumes and a second tick draws and triggers nothing again; a failure in
the very transaction that stores the winner leaves no winner and no revealed secret, and the
next tick completes. Mutation-checked: without the `_tick` branch all three fail.
Docs: the B-81 `*Stato:*`/`*Oggi:*` lines are gone (RND-01, RND-15, RND-36, ARC-27, OPS-09,
ADR-12, ADR-25), OPS-13 no longer prescribes the manual `closing` workaround, LIM-01 is
struck, storico-bug.md and BUGS.md updated. No existing test changed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`server.ping` keeps succeeding on a server that is stalled, and nothing compared its tip
with the others', so it stayed the active server for ever.
- The listener records when the last header was accepted (monotonic clock, a module
attribute so tests can move it).
- After every successful ping, ElectrumListener._check_not_stalled: once the last accepted
header is older than 360s the other servers are asked for their tip
(blockchain.headers.subscribe, ELC-14's timeouts); if any reports a higher height than
ours the session ends with ServerStalledError and run() rotates to the next server. If
none does the chain is just quiet and the session goes on.
No existing test changed. ELC-29 and /admin/electrum (the connection state and the age of
the last header, B-92) are untouched.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
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>