Author SHA1 Message Date
davideandClaude Sonnet 5.5 db24287ea5 Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
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>
2026-10-14 16:29:09 +02:00
davideandClaude Sonnet 5.5 82d078e2d6 Rotate away from a connected server that stopped following the chain (B-86, ELC-21, ARC-26)
`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>
2026-10-14 16:01:44 +02:00
davideandClaude Sonnet 5.5 50b68d2957 Follow and record chain reorganisations, pausing on deep ones (B-85, ELC-09, ELC-22, ELC-23, ELC-24, AUD-38, DAT-69)
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>
2026-10-14 15:56:21 +02:00
davideandClaude Sonnet 5.5 ca50ef62f3 Require real proof of work in headers and link every advance to the tip (B-74, ELC-11, ELC-12, ELC-22, ELC-23)
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>
2026-10-14 15:45:34 +02:00
davideandClaude Sonnet 5.5 a3c51719bd Confirm transactions by merkle proof and reinstate coins only on a quorum (B-83, TX-06, TX-22, TX-23, ELC-25, ELC-26, DEP-02, DEP-08, AUD-18, ADR-07)
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>
2026-10-14 15:29:30 +02:00
davideandClaude Sonnet 5.5 a4b160e240 Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
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>
2026-10-14 15:07:09 +02:00
davideandClaude Sonnet 5.5 f936d6660c Track every version of a bumped transaction and write the RBF replacement before broadcasting it (B-77, TX-12, TX-13, TX-19, TX-21, DAT-13, DAT-14, DAT-34, DAT-44, DAT-45, DAT-70, DAT-71)
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>
2026-10-14 14:46:43 +02:00
davideandClaude Sonnet 5.5 2b6ddb019b Never roll back an ambiguous broadcast as if it had been refused (B-76, TX-04, TX-05, TX-20, TX-21, BET-16, BET-17, BET-23, WDR-11, WDR-12, WDR-16, API-13)
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>
2026-10-14 14:22:20 +02:00
davideandClaude Sonnet 5.5 13cb66cc06 Require a real Electrum quorum and allow certificate pinning (B-84, ELC-01, ELC-03, ELC-14, ELC-19, ARC-20, OPS-12)
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>
2026-10-14 14:04:35 +02:00
davideandClaude Sonnet 5.5 fd6a91e93d Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
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>
2026-10-14 13:52:32 +02:00
davideandClaude Sonnet 5.5 cc63753439 Test that the migration chain keeps the expression indexes (B-97, DAT-75, DAT-56, ARC-62)
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>
2026-10-14 12:22:08 +02:00
davideandClaude Sonnet 5.5 daec4749c2 Survive oversized Electrum replies and ignore dust deposits (B-75, ELC-20, DEP-01, DAT-10)
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>
2026-10-14 12:14:12 +02:00
davideandClaude Sonnet 5.5 b424f670c5 Restructure docs: docs-first, SSOT, one ADR per decision
- 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>
2026-10-14 12:05:57 +02:00
davideandClaude Sonnet 5.5 7ba8e22bb4 Real SQLite transactions: BEGIN IMMEDIATE and synchronous=FULL (B-88, DAT-74, DAT-52, ARC-07, TX-28, BET-11, DAT-16)
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>
2026-09-29 16:26:01 +02:00
davideandClaude Opus 5.5 4be58709d1 Complete the target spec for the 2026-09-28 review (B-73..B-98)
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>
2026-09-29 09:17:52 +02:00
davideandClaude Sonnet 5 88221b0dbd Document the 2026-09-28 specification review and its fixes (B-73..B-97)
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>
2026-09-28 15:51:13 +02:00
davideandClaude Opus 5.5 61efbb0e37 Replace flowchart/ with a traceable specification in docs/
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>
2026-09-28 12:16:01 +02:00
davideandClaude Opus 5 526a649c8b Say what bets and withdrawals actually exclude (B-70)
The flowchart's WITHDRAW node (E1) stated that a withdrawal cannot happen
together with a bet in progress. The code only serializes the two *builds*
through the per-user lock: a withdrawal is accepted while a bet is still
unconfirmed, as long as confirmed, unspent UTXOs cover it.

CLAUDE.md makes every node of the diagrams binding, so one of the two had
to move, and it is the diagram. The hazard the node was reaching for is
the two transactions picking the same UTXO, and that is already excluded
twice: app/tx/locks.py keeps the builds from overlapping, and select_utxos
skips anything already marked spent_txid. What the node forbade on top of
that is spending untouched, confirmed money — so implementing it as
written would freeze a user's whole balance for a block after every bet
and protect nothing. E1 now describes the real rule, and CLAUDE.md's
per-user-lock paragraph states it is the only exclusion between the two.

Regenerated the A4/A3 PDFs (gitignored, so not in this commit).

The regression test is behavioural, not a wording check: it funds a user
with two confirmed UTXOs, bets (taking the larger), and asserts the
withdrawal goes through on the other one with the bet still unconfirmed
and neither transaction spending the other's input. A second test keeps
the diagram from drifting back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:16:13 +02:00
davideandClaude Opus 5 666cb1a0c9 Retire in-code comments that outlived what they described (B-69)
- app/tx/reconcile.py called the payout retry "a future payout-retry
  routine — still an open gap". It shipped as B-26: clearing payout_txid
  leaves the round in exactly the state _retry_payout_if_due picks up, so
  an abandoned payout rebuilds itself and the log line next to it is an
  alert, not the recovery path. Reading it the old way, an operator would
  go hand-fix a round the scheduler was already retrying.
- app/db/base.py sized the SQLite busy timeout against "five concurrent
  background tasks" and then listed only the non-listener ones; the
  lifespan starts six.
- The third item (app/auth/routes.py citing B-31 where it meant B-33) was
  already correct in the tree; the test pins it so it stays that way.

tests/unit/test_code_comments.py derives the task count from the lifespan's
own create_task calls rather than restating it, so the comment fails the
next time a task is added or removed instead of quietly going stale again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:00:57 +02:00
davideandClaude Opus 5 162ceed40f Describe the code the docs actually ship with (B-68)
CLAUDE.md and README still asserted a state the code had moved past:

- JWT "no revocation (B-34)" — token_version implements exactly that
  revocation, and the tv-claim behaviour (including why the deploy did not
  log everyone out) is worth stating instead of denying;
- /report-bug "a placeholder" — it shipped fully implemented and
  translated, with an admin triage section, a reporter-side status view and
  its own audit event; only /guida is still a stub, and /admin has six
  sections now, not five;
- three stale test counts (CLAUDE.md twice, README once);
- a code map missing app/auth/rate_limit.py, app/api/client_ip.py and
  app/api/routes/bug_reports.py;
- README linking flowchart.mmd (the diagrams live in flowchart/), the
  anchor CLAUDE.md#tech-stack-mvp (gone), and describing
  docs/running-the-server.md as "local venv vs. Docker" after B-44 made
  Docker the only supported way to run the server.

The rate-limiting bullet the audit also flagged already reads correctly.

tests/unit/test_docs_current.py pins all of it: the documented counts must
equal what the suite actually collects, the retired claims must stay
retired, the code map must name those modules, and every relative README
link and CLAUDE.md anchor must resolve. None of this is catchable by
reading the code, which is how it drifted in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:33:32 +02:00
davideandClaude Opus 5 5f6abe5b32 Validate and bound the QR endpoint (B-67)
/qr/{address} is reachable without auth (the dashboard renders it with a
plain <img> tag, which cannot carry a bearer token), and it did two things
it should not: it accepted any plm1-prefixed string matching a shape regex,
without checking the bech32 checksum, and it ran qrcode.make on the event
loop — a free CPU amplifier that also stalled the scheduler and the
listener for the duration of every request.

Validation now goes through is_valid_plm_address, the same check
withdrawals and the admin fee_address validator use, behind a length guard
so an oversized path never reaches embit. The render is memoized per
address in a bounded LRU (valid addresses are cheap to generate, so an
unbounded cache would just move the amplification to memory) and pushed
off the loop with run_in_threadpool, and the response carries a
Cache-Control so a browser stops re-asking for an image that never changes.
The rejection now uses the structured error contract (invalid_address),
which already has its i18n key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:18:22 +02:00
davideandClaude Opus 5 23d58796b6 Refuse to open a round that could not pay its winner (B-66)
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>
2026-08-04 14:13:00 +02:00
davideandClaude Opus 5 c4b2dc3ea2 Advertise the jackpot that will actually be paid (B-65)
participant_count and jackpot_sats were computed over every round_participants
row, while the draw only picks from confirmed participants and the payout only
spends their sats. So the advertised jackpot could exceed the one paid out, and a
player whose bet was later abandoned appeared in the count and then vanished
again.

Counting only confirmed rows would have fixed the arithmetic and broken something
else: the player who just bet would see neither themselves nor their money for a
whole block. So this is the same confirmed/in-flight split the balance already
exposes (balance_sats vs pending_balance_sats): participant_count and
jackpot_sats are now the confirmed, authoritative figures, and
pending_participant_count/pending_jackpot_sats/has_pending_bets report what is in
flight — inclusive figures, not deltas, matching the balance pair's convention.

/'s round card shows the confirmed numbers big and the difference as an amber
"+N in attesa" suffix, reusing .balance-pending's colour for the same "not
settled yet" meaning. The two new spans render from server data through t(), so
they carry no data-i18n and onLanguageChange() repaints them from the last
response — the one-mechanism-per-element rule. Both strings are in all 7
languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:52:10 +02:00
davideandClaude Opus 5 c0314e2bf0 Never swap the tip's hash sideways, and never split it from its height (B-64)
_apply_header's linkage check only fires on a single-block advance, so a header
at the height we already held one for was applied on nothing but its own
self-consistency — and that check, as header_meets_its_own_target's own docstring
says, a server can satisfy with a self-declared easy target. So the one value the
draw is seeded from could be replaced under us at the current height, by a reorg
at the tip or by a single server disagreeing with the rest, with no check able to
speak to it. Separately, a header carrying no hex set tip_header_hex back to None
while advancing tip_height, leaving the two describing different blocks — the
exact pairing that function exists to keep.

Both are now refused without ending the session, unlike the fabrication cases
above them: neither is evidence of a hostile server, and rotating away would cost
us the one connection that also credits deposits and broadcasts transactions.

- A same-height header is ignored (logged when it actually differs). The hash
  committed to for a height is not swapped under us; if ours turns out to be the
  orphan, corroborate_header already refuses to seed a draw from it and the draw
  waits for a further block.
- A hex-less header is ignored outright: nothing to validate, nothing to draw
  from. A server that only ever pushed heights now freezes the draw — visibly,
  via B-36's draw_stalled — instead of costing us the connection.

Because ignoring is not fatal, _run_once additionally refuses to publish the
client when the initial header leaves the tip still unknown, so this cannot
reopen B-63's window from the other side.

The two _run_once tests are bounded with asyncio.wait_for: without their guard
that call waits on session tasks nothing ends, and a regression must fail rather
than hang the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:29:37 +02:00
davideandClaude Opus 5 8a0ebecfcc Never let a draw be seeded by a block that predates the close (B-63)
ElectrumListener._run_once assigned self.client before subscribe_headers()
returned, so there was a window — one round-trip wide, at process start — where
the connection looked alive while tip_height was still its initial 0.
"client is not None" is what every consumer reads as "the chain is reachable",
RoundScheduler._tick included, and a round closing inside that window recorded
tip_at_close = 0. The very first header we then learned about — the current tip,
a block mined *before* the round closed, whose hash was already public while
bets were still open — satisfied tip_height > tip_at_close and became the draw's
entropy. The draw's whole guarantee is that its seed did not exist yet when
betting stopped.

Two changes, defending different things:

- The client is published only once the first header has been applied, so
  "client is not None" now means "reachable *and* we know where the chain is".
  During the window consumers see no connection, which is honest: a bet gets the
  same 503 it already gets while disconnected, and the background tasks skip a
  cycle as they already do.

- _wait_for_next_block treats a baseline of 0 as *unknown*, not as height zero:
  it adopts the first height it learns as the baseline, waits for a block
  strictly after it, and records draw_baseline_tip_unknown so the extra block of
  waiting is explainable from /admin. Unreachable via the listener now, but it is
  the local statement of what the draw requires, and nothing else in that
  function would notice if the invariant stopped holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:01:21 +02:00
davideandClaude Opus 5 8dd913ec59 Always keep a change output, so every tx stays fee-bumpable (B-62)
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>
2026-08-03 23:36:55 +02:00
davideandClaude Opus 5 37cc5eeeb5 Snapshot a round's timing when it opens (B-61)
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>
2026-08-03 23:25:56 +02:00
davideandClaude Opus 5 77e07e87dc Audit-log bug report status changes (B-60)
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>
2026-08-03 22:59:00 +02:00
davideandClaude Opus 5 6246b13247 Corroborate deposit credits, not just external spends (B-59)
A candidate external spend has needed a quorum since B-29, but `value` and
`height` for a *credit* came from the single active connection and went straight
into utxo_events. One hostile or broken server could therefore inflate a user's
displayed balance with outpoints that don't exist. It never spends anyone else's
coins — a bet or withdrawal built on a phantom UTXO is refused at broadcast and
rolled back — but it wedges the balance display and burns build attempts, and on
a custodial platform a balance that isn't real is a support incident either way.
Balances move in both directions; both directions now need the same quorum.

corroborate_utxo_credit asks the other configured servers whether they report
the same outpoint, for the same amount, confirmed. The height itself isn't
compared: a server still catching up reports height 0 and simply doesn't agree,
which is the same answer, while two honest servers can't disagree on the height
of a genuinely confirmed outpoint.

refresh_user gains the phase that shape already implied: find_new_credit_
candidates (new, confirmed, not already held) inside the first session,
corroboration outside any session, then credit_confirmed_utxos over what
survived. Only new outpoints are corroborated — re-checking what we already hold
would open a connection to every other server on every refresh for an answer
that can no longer change anything.

A failed corroboration delays a credit, it never loses one: the next scripthash
notification or DepositReconciler sweep (300s) re-offers the same outpoint, and
the withholding is logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:40:15 +02:00
davideandClaude Opus 5 0aac73e557 Bound registrations with a per-IP quota instead of failure backoff (B-58)
The registration throttle called record_failure on every attempt, successful
ones included. Five legitimate signups from one shared or NAT address locked the
sixth real user out for up to 600s, doubling from there — while an attacker
sidestepped the limiter entirely through B-54. Failure backoff is the wrong
instrument here: nothing about creating an account is a failed guess at a
secret, so the only people it reliably punished were the honest ones.

RollingQuota says what was actually meant: 5 accounts per IP per hour, in a
rolling window. The caller over it waits exactly until the oldest of the five
ages out — an accurate Retry-After, and waiting never makes the next wait
longer. It is recorded only once an account exists, so attempts that create
nothing (a taken username, a validation error) leave the quota untouched, and
checked before the Argon2 hash, so an IP out of quota costs nothing to refuse.

Bounded like the failure limiter (B-56): the keys are caller-chosen, so the dict
gets both a sweep and a hard cap, evicting keys with room left in their quota
before full ones.

Also fixes the inline comment that cited B-31 (the resubscribe finding) where it
meant B-33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:32:20 +02:00
davideandClaude Opus 5 57721355f0 Make usernames one case-insensitive namespace (B-57)
The login throttle keyed on body.username.lower() while the lookup matched
User.username exactly, so "Bob" and "bob" were two accounts sharing one
rate-limit bucket — each able to lock the other out — and registration happily
accepted near-duplicate names, which on a custodial system is an impersonation
vector.

Uniqueness is now the database's job: a unique index on lower(username), with
register and login both matching through func.lower(). The name is still stored
exactly as typed, since that's what /admin and the audit log display, and the
username pattern is ASCII-only so lower() is the whole of the normalization.

The migration refuses to run if two existing accounts differ only by case. It
can't merge or rename one automatically: both are custodial accounts that may
hold funds, so that would be the migration silently deciding who owns what. It
names the collisions and leaves them to the operator — the container runs
`alembic upgrade head` at startup, so it surfaces as a refusal to start rather
than a half-applied schema. Verified both directions against a scratch DB, plus
`alembic check` (clean) and the collision guard actually firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:27:15 +02:00
davideandClaude Opus 5 ab65728bdc Bound the rate limiter's bucket dict (B-56)
_buckets is keyed by strings the caller chooses — any username, and (before
B-54) any IP — and only ever grew: decay_seconds aged a bucket's counter but
never removed the entry, so hammering login with random usernames was an
unbounded memory leak.

A bucket is "spent" once its lockout has expired *and* its failure count would
decay to zero on the next failure anyway — at which point keeping it and
dropping it are indistinguishable, which is what makes eviction safe. Those are
swept on record_failure (at most once every 60s) and on the read path, so a key
that's merely being probed never leaves an entry behind. That alone holds the
dict at the size of the genuinely active attack surface.

_MAX_BUCKETS = 50_000 is the backstop for a burst faster than the sweep
interval, when nothing has had time to expire. Over it, the entries closest to
expiry go first: what an attacker gets from a successful flood is the loss of
the shallowest, nearly-over lockouts, never the deep ones actually holding an
attack back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:22:51 +02:00
davideandClaude Opus 5 9c7befe595 Hash and verify passwords off the event loop (B-55)
Argon2 is deliberately expensive — tens of milliseconds of CPU per call. Called
inline from the async handlers for register, login, change-password and the
admin reset, that cost froze the entire process for its duration: every other
request, plus all six background tasks (scheduler, confirmation poller, RBF
bumper, listener, both reconcilers). A burst of unauthenticated login attempts
was therefore not just slow logins, it delayed draws and confirmations.

hash_password_async/verify_password_async wrap the existing pair in
run_in_threadpool, and every async caller now uses them. The synchronous
functions stay: they're what the wrappers call, and what tests and scripts (no
running loop) use directly.

The regression test runs a heartbeat task alongside the hashing and counts how
often the loop got to run it — 1 tick with the old inline call, many with the
threadpooled one.

Also drops the running "already fixed and removed" list from BUGS.md: the file
tracks open findings, and `git log --all --grep 'B-nn'` is the record of how a
closed one was closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:18:33 +02:00
davideandClaude Opus 5 421fe72a8b Record B-54's fixing commit in BUGS.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:14:03 +02:00
davideandClaude Opus 5 907e32e9e0 Make X-Forwarded-For trustworthy instead of attacker-controlled (B-54)
client_ip() read the first element of X-Forwarded-For, which is correct only if
the proxy replaces the header. Caddy appends the peer address to whatever the
client sent, so element 0 was whatever the caller claimed: rotating a fake value
per request minted a fresh identity every time and walked straight through the
login and registration throttles (B-33) and the SSE per-IP subscriber cap
(B-38). Only the per-username login bucket, which doesn't key on the IP, still
bit.

Both halves of the audit's fix, since they hold independently:

- the Caddyfile overwrites the header with `header_up X-Forwarded-For
  {remote_host}`, so what reaches the app is the actual peer and nothing else.
  This is the one that makes the app's assumption true at the source.
- client_ip() reads the *last* hop rather than the first — the element written
  by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
  sits in front of the app (`app` is only `expose`d on the compose network,
  never published to the host), so that element is the real peer.

An empty or comma-only header now falls back to request.client.host instead of
returning "", which was its own shared-bucket evasion.

Regression tests both sides: two requests spoofing different prefixes must key
to the same IP, and the Caddyfile must keep the header_up directive (checked by
`caddy validate`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:14:03 +02:00
davideandClaude Opus 5 8f3cdcb2f8 Record B-53's fixing commit in BUGS.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:06:29 +02:00
davideandClaude Opus 5 64f62291d2 Close the window where a bet pays into a round it was left out of (B-53)
place_bet commits its participant row as "building" before broadcasting (B-08's
two-phase write), while the scheduler flips the round "open" -> "closing" in one
transaction and counts in-flight participants in another. A bet whose deadline
check passed just before that flip could commit in between: the count saw zero,
so the round drew and paid out over the "confirmed" participants only, while the
bet confirmed normally and its sats landed in the pool address — credited to no
round, to no participant, with no refund path, silently improving the next
round's payout change.

Two locks on the same door:

- place_bet re-checks the deadline after building and signing (the first check
  happens before the UTXO scan, so a slow build could carry a bet past it), then
  commits the participant row behind a compare-and-set on the round's own row,
  UPDATE rounds ... WHERE status = 'open'. That UPDATE takes SQLite's write lock,
  so the two transactions can no longer interleave: either the bet commits first
  and the scheduler's in-flight count sees it, or the flip commits first and the
  guard matches zero rows and refuses the bet with round_closing before anything
  is broadcast. A write-snapshot conflict (OperationalError) is the same
  situation and gets the same answer. Nothing has been broadcast at that point,
  so the rollback releases the UTXOs and leaves no rows behind.

- _close_and_draw re-counts in-flight bets in the same session it snapshots the
  participants from, and returns with the round still "closing" if it finds any.
  Redundant given the CAS, and cheap: it fails safe and the next tick retries.

No new error code — a bet refused this way is exactly the "round is closing"
case the user already sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:06:21 +02:00
davideandClaude Opus 5 025754c860 Cap participants per round and give the payout its own input limit (B-52)
The payout has to spend one pool UTXO per bet, so reusing MAX_TX_INPUTS (50)
for it made any round past ~50 players unpayable: select_utxos raised
too_many_inputs, the round stayed "paying_out" retrying every 60s forever, and
since no new round may open while one is active, the whole lottery stopped with
the pool stuck. The cap was being enforced on the payout side, i.e. discovered
once the money was already committed and there was no way back.

Two halves:

- select_utxos takes the cap as a parameter. Bets and withdrawals keep
  MAX_TX_INPUTS = 50, which protects a user from a fee that eats into the amount
  they are moving; the payout uses MAX_PAYOUT_TX_INPUTS = 500, where that
  argument doesn't apply — 400 inputs at 1 sat/vB cost ~0.00027 PLM out of the
  winner's 70% share. What actually bounds it is relay policy: 500 inputs is
  ~34 kvB against the 100 kvB standardness limit, and signing that many measures
  ~0.4s, once per round, inside a background task.

- place_bet refuses the 401st bet with a new round_full error (translated into
  all 7 languages), so "a round can always be paid out" is an invariant checked
  before any money moves. MAX_PARTICIPANTS_PER_ROUND sits below the input cap to
  leave the payout headroom for pool change from earlier rounds, and counts every
  participant row rather than only confirmed ones, since a failed bet frees a slot.

A round already wedged past the old cap now pays out on the next retry tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:21:33 +02:00
davideandClaude Opus 5 99d7a1ee00 Open the 2026-08-03 audit list (B-52 … B-72)
Third full-codebase audit, after the two earlier lists (B-01 … B-24 and
B-25 … B-49) were emptied and BUGS.md deleted. Analysis only — nothing is
fixed yet; each entry gets its own commit with its own regression test.

Branched off feature/bug-reports rather than main on purpose: the audit
describes this tree, /report-bug included (see B-68).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:51:00 +02:00
169 changed files with 22518 additions and 1049 deletions

No files matched your search

+20 -5
View File
@@ -3,17 +3,32 @@ ELECTRUM_PORT=50002
ELECTRUM_USE_SSL=true
# Additional Electrum servers to fall back to, comma-separated. Each entry is
# `host:port` (TLS, the normal case) or `host:port:notls`. The listener rotates
# over the primary above plus these, so one unreachable server costs a single
# reconnect attempt instead of an outage — every deposit credit, broadcast and
# `host:port` (TLS, the normal case), `host:port:notls`, or `host:port:pin=<sha256>`
# (TLS, and the server's DER certificate must hash to that SHA-256, in hex). The
# listener rotates over the primary above plus these, so one unreachable server costs
# a single reconnect attempt instead of an outage — every deposit credit, broadcast and
# confirmation goes through this one connection, which makes a single server the
# platform's biggest single point of failure. A typo here fails at startup rather
# than during the outage when the fallback is what you need.
#
# AT LEAST 2 are required (B-84): every decision that moves money is corroborated by
# the *other* servers and needs two independent answers, so with fewer than two
# fallbacks the server refuses to start (override: ALLOW_NO_QUORUM, development only).
# Configure three or more in production, so one being down does not stop the lottery.
#
# These are the mainnet bootstrap servers from the PalladiumWallet repo
# (src/Core/Chain/ChainProfiles.cs, ChainProfiles.Mainnet.BootstrapServers) — the
# same ones the reference wallet falls back to:
# Example: ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
ELECTRUM_FALLBACK_SERVERS=
ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
# Optional: SHA-256 (hex) of the primary server's DER certificate. A connection that
# presents another certificate fails like an unreachable server. Empty = not pinned.
ELECTRUM_CERT_PIN=
# Development only: start with fewer than 2 fallback servers (corroboration then falls
# back to the weak rule: a single answer decides, or nothing is checked at all).
# NEVER true in production.
ALLOW_NO_QUORUM=false
# Fernet key protecting the master xprv at rest. Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
-1
View File
@@ -8,4 +8,3 @@ master.xprv.enc
*.egg-info/
logs/
data/
flowchart/*.pdf
+260
View File
@@ -0,0 +1,260 @@
# BUGS.md — open findings
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
2026-09-28 specification review (B-73 … B-98, at the end of the file).
## Audit of 2026-08-03
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
fixed finding, B-51.
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
removed from this file once it is fixed, and is not listed here afterwards. Per
CLAUDE.md's convention each entry gets its own commit with its own regression test,
and the `B-nn` marker goes in a comment next to the fix, so
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
still empty; withdrawal and the RBF bump path still never live-broadcast.
Verified as *not* broken while looking for these: i18n key parity (146 identical
keys across all 7 languages), HTML escaping of every user-controlled value in
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
secrets/DB/logs (nothing sensitive is tracked in git).
Severity is about consequence, not likelihood:
**critical** = money stuck or lost, or the lottery stops;
**high** = a security control that does not hold;
**medium** = wrong behaviour with a bounded blast radius;
**low** = drift between documentation and code.
---
## Critical
### (not new) `drawing` does not resume after a restart
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
here. Worth restating in context: with `restart: unless-stopped` on the app
container, this is the one state that gets stuck with money in play, and it
remains the last prerequisite for running unattended.
---
## Low — documentation and consistency drift
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
working tree (both gitignored). They were verified during this audit to decrypt to
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
reads a different file from the one the container uses, and a future
`generate_master_key.py --overwrite` would split them silently, with an ops
recovery path that then reports the wrong key.
The same duplication exists for the database (`./plm_lottery.db` next to
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
and describes the master-key step as local *or* Docker, while B-44 made Docker the
only supported way to run the server (`docs/running-the-server.md` and README were
updated, this file was not). The venv genuinely is needed for tests, migrations and
the key scripts — the wording just needs to say that instead of implying a second
way to run the server.
---
# Audit of 2026-09-28 — specification review (B-73 … B-98)
Fourth audit, run against the new specification in `docs/` rather than the code
alone: four independent reviews (coin/chain mechanics, money state machine,
DevOps, spec cross-validation), with the critical claims re-verified in code.
The target behaviour of every entry below is now written into `docs/` as
requirements marked `*Stato:* da implementare (B-nn)`; the Italian overview, the
design decisions and the implementation order are in
[docs/piano-correzioni.md](docs/piano-correzioni.md). The code has **not** been
changed yet. Same convention as above: one commit per entry, with its own
regression test, spec + code + test together.
Design decisions taken with the owner on 2026-09-28 (they change existing
decisions, see `docs/limiti-noti.md`):
- **Draw by commit-reveal.** The winner must not be computable by anyone who
controls block production, since PLM's hashrate (~22 TH/s) is rentable.
- **Never release funds on doubt.** A money-moving row is rolled back only on proof
(an explicit rejection, or its inputs confirmed-spent elsewhere), never on a
timeout or on one server's silence.
- **One pool address per round**, so a round's money, history and payout are
isolated from every other round's.
- **Deposits need 6 confirmations** (configurable), everything else keeps 1: the
deposit is the only input a user can double-spend.
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
on deep ones, and pay out only what is verifiably on chain.
## Critical
### B-79 — A payout normally has no change output, so it cannot be fee-bumped
The pool normally holds exactly the round's bet outputs, the payout spends them all
for exactly `pool_amount_sats`, so there is no change and `bump_fee` can never help.
A payout stuck in a mempool is also never abandoned (the server still knows it): the
round stays in `paying_out` and no new round opens.
Fix: every payout carries a **bump reserve** output of
`max(DUST_LIMIT_SATS, estimated_vsize × 10 sat/vB)`, taken from the commission share
and paid to the pool reserve address (`m/84'/746'/0'/1/0`, the old pool address);
RBF shrinks that output. `rounds_can_open` refuses a configuration in which a
round of 1 or of `MAX_PARTICIPANTS_PER_ROUND` participants could not cover winner
fee + reserve + dust commission, and `PUT /admin/config` refuses a bet amount or
fee rate that would fail the same check (spec: RND-40).
### B-81 — `drawing` wedges on any exception, not only on restart
The participant snapshot and `tip_at_close` live only in memory; `_tick` skips
`drawing`. Any exception during the wait (`database is locked` writing an audit row,
a malformed header) leaves the round in `drawing` forever.
Fix: persist `draw_height`, `participants_hash` and each participant's
`draw_position` when entering `drawing`; `_tick` re-enters the wait for a round in
`drawing`, idempotently. Together with B-73's fixed height and committed seed, a
resumed draw always yields the same winner.
### B-82 — A deposit reorged out after a bet leaves the round unpayable
Deposits are credited at 1 confirmation. A user can bet, then (if they lost)
double-spend the deposit the bet spent with a private chain: the bet disappears but
its participant stays `confirmed` and counted, and the payout fails "insufficient
pool UTXOs" forever with every other player's money stuck. Immature coinbase outputs
are also credited at 1 confirmation (maturity is 120).
Fix: deposits credited at `deposit_confirmations` (round_config, default 6),
coinbase outputs at 120. Before building a payout the round address's UTXOs are
verified with the quorum; participants whose bet outpoint is gone are marked
`reorged_out` and the 70/30 split is computed on what is actually there. If the
winner's own bet is gone the payout is held (`payout_blocked`) and the lottery pauses.
## High
### B-87 — The RBF bump can leave a dust change output
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
by relay, every 30s.
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
dropped and its value goes to the fee (the replacement is then final); `RbfError`
and every refused bump write a `rbf_bump_failed` audit row.
### B-89 — Key problems surface only at first use
Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty;
a wrong key or a different xprv is found at the first registration or payout, and a
different xprv silently moves the pool address. The scripts accept any xprv and write
files `0644`.
Fix: at startup decrypt the key and check the pool reserve address and a sample of
stored user addresses; refuse to serve on mismatch. The scripts refuse an xprv whose
derived addresses differ from the DB's and write `0600`.
### B-90 — Restoring a backup is unsafe
A restored DB reopens rounds that were already drawn and paid, and reassigns the
derivation indexes of users registered after the backup to new users — who then
receive the old users' funds.
Fix: `START_PAUSED=true` starts with the lottery paused; at startup the next 20
indexes past the highest assigned are checked for on-chain history and, if any has
some, registration is refused until the operator reconciles (`restore_gap_detected`);
per-round addresses (B-80) and B-82's pre-payout check make a replayed round
unpayable instead of payable twice.
### B-91 — Host and supply-chain hardening
The container runs as root, `.env` and key files are world-readable, dependencies are
unpinned `>=` and base images float, secrets sit in env vars visible to
`docker inspect`.
Fix: non-root `USER`, `0600`/`0700` permissions, a hash-pinned lock file,
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
## Medium
### B-92 — No health, readiness or real audit browsing
`/health` always answers ok; nothing exposes Electrum state; the audit endpoint
returns only the latest 500 rows unfiltered; registration writes no audit row; the
access log records Caddy's IP; app logs go only to the file.
Fix: `GET /health/ready` (Electrum connected, tip age < 360s, scheduler heartbeat <
30s) used as the container healthcheck; Electrum status in `/admin`; audit filters by
event type, round, user and time, with pagination; `user_registered` audit row with
index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
### B-93 — Deploys and rollbacks
A deploy with an open SSE stream never shuts down gracefully (uvicorn waits, Docker
kills after 10s); an older image cannot start on a newer DB; images are untagged;
`SITE_ADDRESS` passed per command reverts to `localhost` on the next plain
`docker compose up`.
Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task
cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`;
documented pre-deploy backup and downgrade procedure.
### B-94 — Account and endpoint abuse limits
`change-password` has no rate limit, no audit and no maximum length; login fields
have no length bounds; `POST /bug-reports` is anonymous and unlimited;
`GET /admin/users` has no pagination; admin, SSE and framework errors are bare
strings; static JS/CSS have no `Cache-Control`.
Fix: throttle and audit (`password_changed`) the password change, cap every
credential field at 256, rate-limit bug reports per IP, paginate the user list,
structured errors everywhere, `Cache-Control: no-cache` on static files.
### B-95 — Round parameters read live instead of snapshotted
Only duration and cooldown are snapshotted at open; bet amount, `fee_address` and the
new `draw_confirmations` are read live, so an edit mid-round changes the round in
progress (e.g. redirects its commission).
Fix: snapshot `bet_amount_sats`, `fee_address` and `draw_confirmations` on the round
at open. Optional per-round cap `max_round_pool_sats` (round_config, 0 = disabled):
once reached, further bets get `round_full`.
### B-96 — Smaller state inconsistencies
A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned
payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writes no
`payout_sent` and a promoted bet no `bet_placed`; the reconciler pings SSE for every
row it checks; concurrent refreshes of one user can collide on the unique outpoint
and end the Electrum session; `listener.client` is read outside the per-user lock;
a bet covered only by pending change gets `insufficient_balance` instead of
`balance_pending_confirmation`. A round waiting in `closing` on a bet that never
leaves the mempool gives no signal at all.
Fix: each as described, one regression test per item; for the `closing` wait, a
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
on `GET /rounds/current` (spec: TX-29).
### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon
as the selected total covers the target. When that happens exactly (a 10 PLM coin
for a 10 PLM bet), the change is 0: the bet is refused with
`balance_leaves_no_change` and a withdrawal is reduced by `DUST_LIMIT_SATS`, even
though a second coin would have left a proper change output.
Fix: select until the total covers `target + DUST_LIMIT_SATS` (still largest-first,
still capped at `max_inputs`); only when every allowed coin together cannot reach it
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
`docs/scommesse-e-prelievi.md` BET-24.
+68 -43
View File
@@ -8,15 +8,31 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 253 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.
All 10 stages of the original build order are code-complete and unit-tested — 617 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 (1-conf), 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. They were tracked in a `BUGS.md` that was deleted once the list emptied, 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.
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](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](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](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.
Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either.
**[docs/](docs/README.md) 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](docs/come-funziona.md) and the ID convention and change workflow are in [docs/processo-di-modifica.md](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/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/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).
Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): the specification starting at [come-funziona.md](docs/come-funziona.md), the operational guides [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/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](docs/README.md)** — the index of everything under `docs/`. Three rules, all in [docs/processo-di-modifica.md](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/](docs/adr/README.md)); *what* (verifiable rule) → a `PREFIX-NN` requirement in the domain document; *how* → the code; accepted limits → [limiti-noti.md](docs/limiti-noti.md) (`LIM-nn`); found/fixed defects → [storico-bug.md](docs/storico-bug.md) (`B-nn`, open ones in [BUGS.md](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](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](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](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](docs/README.md) (`RND`, `BET`, `WDR`, `DEP`, `REG`, `TX`, `ELC`, `AUD`, `ARC`, `DAT`, `API`, `SEC`, `OPS`) |
| Narrative overview | [docs/come-funziona.md](docs/come-funziona.md) |
| Accepted limits | [docs/limiti-noti.md](docs/limiti-noti.md) |
| Fix order for open B-nn | [docs/piano-correzioni.md](docs/piano-correzioni.md) |
## Commands
@@ -33,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
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 253 tests
python -m pytest # all 617 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
```
@@ -55,13 +71,13 @@ 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`. `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` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
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` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
## 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, **no revocation** — B-34).
- 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.
@@ -84,35 +100,40 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|---|---|---|
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit |
| Round duration / cooldown | 600s / 30s | `RoundConfig` |
| 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) |
| 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, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
| Max inputs per tx | 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 *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).
`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 flowchart): gap after a round closes before the next opens, so players can see the outcome.
**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.
**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` so far, `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.
## 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`; `app/api/errors.py` holds the error contract |
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` |
| `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 + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
| `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 + winner pick), `config.py`, `events.py` (SSE pub/sub) |
| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
| `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
@@ -128,48 +149,48 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s 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. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
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]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). 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.
- **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`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. 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.
- **A quorum corroborates the two money-moving decisions** (`_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). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
- **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. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It 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: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd).
Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vita-del-round.md](docs/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 after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation** and only once the other servers corroborate the outpoint and its amount (B-59), with the 1-conf reorg risk knowingly accepted and no 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**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
**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* (`opened_at + round_duration_seconds`), **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`.
- *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): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
- *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); 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 **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
- *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.
**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.
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** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block.
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 ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`.
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
@@ -192,10 +213,12 @@ Both SPAs refresh on an `update` message *or* on `open` — the latter fires on
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.
- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**, since the network refused it, not the caller.
- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41).
- **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.
`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. `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.
**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.
@@ -203,10 +226,11 @@ Everything that spends money is written **before** it is broadcast and resolved
## 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`.
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 five 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; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
- **`/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`.
@@ -219,7 +243,7 @@ Both talk to the same JSON API; there's no admin/user API split beyond `require_
- `/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 never special-case unexpected errors, 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.
**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
@@ -230,6 +254,7 @@ Explicit design choices, not derivable from any single file — respect them:
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) 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 change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
@@ -237,12 +262,12 @@ Explicit design choices, not derivable from any single file — respect them:
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer 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). Restart-resumption itself remains the last prerequisite for running unattended.
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_complete_draw` after a crash (everything it needs is in the database since B-73, only the `_tick` branch is missing — B-81). That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer 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). Restart-resumption itself remains the last prerequisite for running unattended.
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
- **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.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). 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.
- **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 anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
- **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.
+11 -1
View File
@@ -29,5 +29,15 @@
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
}
reverse_proxy app:8123
# B-54: Caddy *appends* the real peer address to whatever X-Forwarded-For the
# client sent, so without this the header arrives as "<whatever the client
# claimed>, <real ip>" and every IP-keyed control in the app (the login and
# registration throttles of B-33, the SSE per-IP subscriber cap of B-38) is
# defeated by simply rotating a fake value per request. Overwriting the header
# with the actual peer makes the app's assumption true at the source; it also
# reads the last hop rather than the first (app/api/client_ip.py), so the two
# defences hold independently.
reverse_proxy app:8123 {
header_up X-Forwarded-For {remote_host}
}
}
+5 -4
View File
@@ -41,9 +41,10 @@ domain).
## Documentation
- [CLAUDE.md](CLAUDE.md) — architecture, commands, domain decisions, known gaps (for anyone/anything working on the code)
- [flowchart.mmd](flowchart.mmd) — the source-of-truth flow diagram the implementation follows node-by-node
- [docs/README.md](docs/README.md) — **index of all documentation** (docs-first, single source of truth, one ADR per decision: [docs/adr/](docs/adr/README.md))
- [docs/come-funziona.md](docs/come-funziona.md) — the specification (Italian): how the platform works, with diagrams and traceable requirement IDs; it indexes every per-topic document (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history). [docs/processo-di-modifica.md](docs/processo-di-modifica.md) describes how a change goes from the spec to the code
- [docs/setup.md](docs/setup.md) — one-time setup (secrets, master key, migrations)
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it (local venv vs. Docker+Caddy, dev vs. production TLS)
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it with Docker+Caddy (dev vs. production TLS)
- [docs/guida-utente.md](docs/guida-utente.md) — end-user guide to the test UI (Italian)
- [docs/guida-admin.md](docs/guida-admin.md) — admin dashboard guide (Italian)
@@ -51,7 +52,7 @@ domain).
Python (FastAPI, SQLAlchemy async + Alembic, Argon2 + JWT auth), Electrum
protocol for PLM network access (no full node), Docker + Caddy for
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack-mvp) for the complete list
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack) for the complete list
and the reasoning behind each choice.
## Testing
@@ -61,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
232 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
617 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+15 -1
View File
@@ -10,8 +10,22 @@ def client_ip(request: Request) -> str:
Shared by the login/registration throttles (B-33) and the SSE per-IP
subscriber cap (B-38) so the two can't drift into different notions of
"the client's IP".
B-54: the *last* element, not the first. A proxy appends the address it saw
to any X-Forwarded-For the client already sent, so the first element is
attacker-controlled — with the header read from the front, rotating a fake
value per request gave every request a fresh identity and turned all three
IP-keyed controls above into decoration. The last element is the one written
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
sits in front of this app (Caddy, see docker-compose.yml, where `app` is
only `expose`d on the compose network and never published to the host), so
the last element is the real peer. The Caddyfile now also overwrites the
header with `header_up X-Forwarded-For {remote_host}`, which collapses it to
a single value — belt and braces: either fix alone closes B-54.
"""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
if hops:
return hops[-1]
return request.client.host if request.client else "unknown"
+51 -7
View File
@@ -8,11 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.auth.security import hash_password_async
from app.config import settings
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
from app.wallet.address import is_valid_plm_address
from app.wallet.hd import derive_user_wif
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
@@ -44,6 +45,7 @@ _CONFIG_FIELDS = (
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"draw_confirmations", # B-73 (DAT-62)
)
@@ -55,7 +57,9 @@ class RoundConfigResponse(BaseModel):
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
draw_confirmations: int # B-73 (DAT-62)
paused: bool
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
class RoundConfigUpdate(BaseModel):
@@ -75,6 +79,7 @@ class RoundConfigUpdate(BaseModel):
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
@field_validator("fee_address")
@classmethod
@@ -93,6 +98,7 @@ class RoundConfigUpdate(BaseModel):
def _config_response(config) -> RoundConfigResponse:
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
fields["paused"] = config.paused
fields["paused_reason"] = config.paused_reason
return RoundConfigResponse(**fields)
@@ -128,14 +134,29 @@ async def update_config(
return _config_response(config)
class PauseRequest(BaseModel):
note: str | None = Field(default=None, max_length=256)
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
async def pause_lottery(
body: PauseRequest | None = None, session: AsyncSession = Depends(get_session)
) -> RoundConfigResponse:
"""Maintenance switch: the round in progress (if any) still closes, draws,
and pays out its winner normally — only opening the *next* round is
suppressed until /admin/resume is called (rounds/service.py)."""
suppressed until /admin/resume is called (rounds/service.py).
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
already in force stays — it is the one that needs a look. The audit row always
says `operator` for this call."""
config = await get_round_config(session)
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
config.paused_reason = OPERATOR_REASON
config.paused = True
await write_audit_log(session, "lottery_paused", {})
payload = {"reason": OPERATOR_REASON}
if body is not None and body.note:
payload["note"] = body.note
await write_audit_log(session, "lottery_paused", payload)
await session.commit()
return _config_response(config)
@@ -143,8 +164,12 @@ async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundCo
@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
config = await get_round_config(session)
# B-78 (AUD-12): the reason the resume took away, so the log says what the operator
# agreed to resume (None when the lottery was not paused).
lifted = config.paused_reason if config.paused else None
config.paused = False
await write_audit_log(session, "lottery_resumed", {})
config.paused_reason = None
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
await session.commit()
return _config_response(config)
@@ -217,7 +242,7 @@ async def reset_user_password(
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
new_password = secrets.token_urlsafe(12)
user.password_hash = hash_password(new_password)
user.password_hash = await hash_password_async(new_password)
# B-34: this endpoint exists precisely for the "account compromised" case —
# without bumping token_version, whoever was already logged in (the
# attacker, if that's who prompted the reset) stayed logged in on their
@@ -241,6 +266,9 @@ class AdminRoundResponse(BaseModel):
winner_amount_sats: int | None
fee_amount_sats: int | None
payout_txid: str | None
pool_address: str | None # B-80
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
@@ -268,6 +296,9 @@ async def list_rounds(
winner_amount_sats=r.winner_amount_sats,
fee_amount_sats=r.fee_amount_sats,
payout_txid=r.payout_txid,
pool_address=r.pool_address or None,
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
)
for r in rounds
]
@@ -408,7 +439,20 @@ async def update_bug_report_status(
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
report.status = body.status
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
# password reset) leaves a trace; this one silently marked a report `resolved`.
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
# only accountability there is. Before/after like config_updated, and nothing
# written when the status doesn't actually change — re-clicking the status a
# report already has isn't an event.
if report.status != body.status:
await write_audit_log(
session,
"bug_report_status_changed",
{"report_id": report_id, "from": report.status, "to": body.status},
user_id=report.user_id,
)
report.status = body.status
await session.commit()
username = None
+10 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Depends, Request, Response, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,6 +21,7 @@ class BetResponse(BaseModel):
@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED)
async def create_bet(
request: Request,
response: Response,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> BetResponse:
@@ -34,17 +35,24 @@ async def create_bet(
async with request.app.state.user_locks.acquire(user.id):
try:
participant = await place_bet(session, listener.client, user)
participant = await place_bet(session, listener.client, user, network=listener)
except BetError as exc:
# A rejected broadcast isn't the client's fault — it's the network
# refusing our transaction, so it answers 502 rather than 400 (B-07).
code = (
status.HTTP_502_BAD_GATEWAY
if exc.code == "broadcast_failed"
else status.HTTP_503_SERVICE_UNAVAILABLE # B-88: write lock not granted, nothing sent
if exc.code == "service_busy"
else status.HTTP_400_BAD_REQUEST
)
raise from_api_error(code, exc) from exc
if participant.status == "building":
# B-76 (TX-20, BET-23): the outcome of the broadcast is uncertain — the tx may
# already be on the network — so the bet is accepted for processing, not
# confirmed as sent, and the reconciler settles it.
response.status_code = status.HTTP_202_ACCEPTED
return BetResponse(
round_id=participant.round_id,
bet_txid=participant.bet_txid,
+46 -10
View File
@@ -1,22 +1,58 @@
"""PNG QR codes for PLM addresses.
Deliberately unauthenticated: the dashboard renders it with a plain `<img>`
tag, which cannot carry the bearer token, and the payload is an address the
caller already has. What the endpoint must not be is a free CPU amplifier
(B-67), so two things bound the work an anonymous caller can ask for:
- the address is validated for real (bech32 checksum + PLM HRP) via
`is_valid_plm_address`, the same check withdrawals and the admin
`fee_address` validator use, instead of a shape-only regex that happily
rendered a QR for any `plm1`-prefixed junk string;
- the render itself is memoized per address and pushed off the event loop, so
a repeat request costs a dict lookup and a first one never blocks the
scheduler, the listener or any other request.
"""
import io
import re
from functools import lru_cache
import qrcode
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response
from app.api.errors import http_error
from app.wallet.address import is_valid_plm_address
router = APIRouter(tags=["qr"])
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
# Long enough for any bech32 address, short enough that a multi-kilobyte path
# is rejected before embit ever looks at it.
_MAX_ADDRESS_LENGTH = 100
# Bounded on purpose: valid addresses are cheap to generate, so an unbounded
# cache would just move the amplification from CPU to memory.
_CACHE_SIZE = 512
@lru_cache(maxsize=_CACHE_SIZE)
def _render_png(address: str) -> bytes:
image = qrcode.make(address)
buf = io.BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
@router.get("/qr/{address}")
async def address_qr(address: str) -> Response:
if not _ADDRESS_RE.match(address):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid address")
if len(address) > _MAX_ADDRESS_LENGTH or not is_valid_plm_address(address):
raise http_error(400, "invalid_address", "not a valid PLM bech32 address")
image = qrcode.make(address)
buf = io.BytesIO()
image.save(buf, format="PNG")
return Response(content=buf.getvalue(), media_type="image/png")
png = await run_in_threadpool(_render_png, address)
# An address' QR never changes; let the browser stop asking for it.
return Response(
content=png,
media_type="image/png",
headers={"Cache-Control": "private, max-age=86400, immutable"},
)
+154 -10
View File
@@ -9,13 +9,14 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_optional_user
from app.db.models import RoundParticipant, User
from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
from app.rounds.service import get_active_round, winner_share
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
router = APIRouter(prefix="/rounds", tags=["rounds"])
@@ -89,11 +90,26 @@ class CurrentRoundResponse(BaseModel):
server_time: str
round_id: int | None = None
status: str | None = None
# B-80 (RND-35): this round's own pool address, so anyone can verify its money on
# an explorer; null when there is no active round.
pool_address: str | None = None
opened_at: str | None = None
closes_at: str | None = None
# B-65: confirmed participants only — the ones the draw actually picks from and
# whose sats are actually in the pool. The pending_* pair below is the same
# confirmed/in-flight split the balance already exposes (see
# wallet/balance.py's balance_sats vs pending_balance_sats), and for the same
# reason: the authoritative number must be the one that will be paid, while the
# player who just bet still needs to see their own bet somewhere.
participant_count: int = 0
bet_amount_sats: int
jackpot_sats: int = 0
# Inclusive of bets still building/broadcast, exactly like pending_balance_sats
# is inclusive of unconfirmed change — not deltas. Equal to the confirmed
# figures above when nothing is in flight, which is what has_pending_bets says.
pending_participant_count: int = 0
pending_jackpot_sats: int = 0
has_pending_bets: bool = False
draw_animation_seconds: int
winner_user_id: int | None = None
winner_amount_sats: int | None = None
@@ -103,8 +119,22 @@ class CurrentRoundResponse(BaseModel):
# waiting for a block" rather than a countdown implying a bounded wait — this
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
draw_waiting_since: str | None = None
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
# revealed).
server_seed_hash: str | None = None
participants_hash: str | None = None
draw_height: int | None = None
draw_confirmations: int | None = None
chain_tip_height: int | None = None
lottery_paused: bool = False
paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused
# B-66: false while the instance is missing configuration a round cannot run
# without (today: fee_address) — no round will open until it's set, so this is
# the difference between "wait, the next round is coming" and "nothing is coming
# until the operator finishes setting this up". Distinct from lottery_paused,
# which is a deliberate operator action rather than an unmet prerequisite.
lottery_configured: bool = True
user_played: bool = False
@@ -126,23 +156,40 @@ async def current_round(
draw_animation_seconds=config.draw_animation_seconds,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
paused_reason=config.paused_reason,
lottery_configured=rounds_can_open(config),
)
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
) or 0
# The pool is the sum of what the participants' bets actually paid into the pool
# address — each one is already net of that bet's network fee. Deriving it from
# participant_count * the *current* bet_amount_sats instead overstated it, and
# silently changed the advertised jackpot of a round in progress whenever an
# operator edited the bet amount (B-11).
pool_amount_sats = await session.scalar(
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
RoundParticipant.round_id == round_.id
#
# Split confirmed from in-flight (B-65): the draw only picks from confirmed
# participants and the payout only spends their sats, so counting every row
# advertised a jackpot larger than the one that would be paid, and made a
# participant appear and then vanish again if their bet was later abandoned.
counts = (
await session.execute(
select(
func.count(),
func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0),
func.count().filter(RoundParticipant.status == "confirmed"),
func.coalesce(
func.sum(RoundParticipant.bet_amount_sats).filter(
RoundParticipant.status == "confirmed"
),
0,
),
).where(RoundParticipant.round_id == round_.id)
)
) or 0
).one()
all_count, all_pool_sats, participant_count, pool_amount_sats = counts
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
# B-61: from the round's own duration — the countdown clients are watching must
# not jump because an operator edited the config mid-round.
closes_at = round_deadline(round_)
# Lets the frontend show the personalized win/lose reveal only to players in
# this round — everyone else (not logged in, or logged in but didn't bet)
@@ -171,18 +218,115 @@ async def current_round(
server_time=datetime.now(timezone.utc).isoformat(),
round_id=round_.id,
status=round_.status,
pool_address=round_.pool_address or None,
opened_at=opened_at.isoformat(),
closes_at=closes_at.isoformat(),
participant_count=participant_count,
bet_amount_sats=config.bet_amount_sats,
jackpot_sats=jackpot_sats,
pending_participant_count=all_count,
pending_jackpot_sats=winner_share(all_pool_sats),
has_pending_bets=all_count > participant_count,
draw_animation_seconds=config.draw_animation_seconds,
winner_user_id=round_.winner_user_id,
winner_amount_sats=round_.winner_amount_sats,
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
server_seed_hash=round_.server_seed_hash,
participants_hash=round_.participants_hash,
draw_height=round_.draw_height,
draw_confirmations=round_.draw_confirmations,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
paused_reason=config.paused_reason,
lottery_configured=rounds_can_open(config),
user_played=user_played,
)
class ProofParticipant(BaseModel):
draw_position: int
txid: str
vout: int
address: str
amount_sats: int
status: str
class RoundProofResponse(BaseModel):
round_id: int
status: str
algorithm: str
pool_address: str | None
server_seed_hash: str | None
server_seed: str | None
draw_height: int | None
draw_confirmations: int
participants: list[ProofParticipant]
participants_hash: str | None
draw_block_hash: str | None
seed: str | None
winner_index: int | None
winner_address: str | None
payout_txid: str | None
@router.get("/{round_id}/proof", response_model=RoundProofResponse)
async def round_proof(round_id: int, session: AsyncSession = Depends(get_session)) -> RoundProofResponse:
"""B-73 (RND-37, API-45): everything needed to recompute a round's winner without
trusting the server — public, unauthenticated, for a round in any state. A field that
does not exist yet is null (or an empty list).
`server_seed` is the plaintext column, which is written only by the reveal (the same
transaction that stores the winner, or the close of a round without participants), so
there is no state of the round in which this endpoint can return it early.
"""
round_ = await session.get(Round, round_id)
if round_ is None:
raise http_error(404, "round_not_found", "round not found")
rows = (
await session.execute(
select(RoundParticipant, User.address)
.join(User, User.id == RoundParticipant.user_id)
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
.order_by(RoundParticipant.draw_position)
)
).all()
participants = [
ProofParticipant(
draw_position=p.draw_position,
txid=p.bet_txid,
vout=p.bet_vout,
address=address,
amount_sats=p.bet_amount_sats,
status=p.status,
)
for p, address in rows
]
seed_decimal = round_.seed_int
winner_index = None
winner_address = None
if seed_decimal is not None and participants:
winner_index = int(seed_decimal) % len(participants)
winner_address = participants[winner_index].address
return RoundProofResponse(
round_id=round_.id,
status=round_.status,
algorithm="commit-reveal-v1" if round_.server_seed_hash else "block-hash-v0",
pool_address=round_.pool_address or None,
server_seed_hash=round_.server_seed_hash,
server_seed=round_.server_seed,
draw_height=round_.draw_height,
draw_confirmations=round_.draw_confirmations,
participants=participants,
participants_hash=round_.participants_hash,
draw_block_hash=round_.draw_block_hash,
seed=seed_decimal,
winner_index=winner_index,
winner_address=winner_address,
payout_txid=round_.payout_txid,
)
+8 -3
View File
@@ -6,7 +6,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
from app.auth.security import (
MIN_PASSWORD_LENGTH,
create_access_token,
hash_password_async,
verify_password_async,
)
from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session
from app.wallet.balance import compute_pending_balance
@@ -59,7 +64,7 @@ async def change_password(
"""Self-service password change — requires the current password, unlike the
admin-only /admin/users/{id}/reset-password (which is for a user who's
actually locked out and can't provide it)."""
if not verify_password(body.current_password, user.password_hash):
if not await verify_password_async(body.current_password, user.password_hash):
raise http_error(
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
)
@@ -71,7 +76,7 @@ async def change_password(
minimum=MIN_PASSWORD_LENGTH,
)
user.password_hash = hash_password(body.new_password)
user.password_hash = await hash_password_async(body.new_password)
# B-34: bumping token_version invalidates every token issued before this
# point — including this very request's own bearer token, and any an
# attacker who knew the old password might be holding. A fresh token is
+9 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Depends, Request, Response, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,7 @@ class WithdrawalResponse(BaseModel):
async def create_withdrawal(
body: WithdrawalRequest,
request: Request,
response: Response,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> WithdrawalResponse:
@@ -41,16 +42,22 @@ async def create_withdrawal(
async with request.app.state.user_locks.acquire(user.id):
try:
withdrawal = await request_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats
session, listener.client, user, body.external_address, body.amount_sats, network=listener
)
except WithdrawalError as exc:
code = (
status.HTTP_502_BAD_GATEWAY # the network refused it, not the caller (B-07)
if exc.code == "broadcast_failed"
else status.HTTP_503_SERVICE_UNAVAILABLE # B-88: write lock not granted, nothing sent
if exc.code == "service_busy"
else status.HTTP_400_BAD_REQUEST
)
raise from_api_error(code, exc) from exc
if withdrawal.status == "building":
# B-76 (TX-20, WDR-16): uncertain broadcast — accepted for processing, the
# reconciler decides whether the transaction left.
response.status_code = status.HTTP_202_ACCEPTED
return WithdrawalResponse(
txid=withdrawal.txid,
amount_requested_sats=withdrawal.amount_requested_sats,
+137 -2
View File
@@ -1,6 +1,15 @@
import time
from dataclasses import dataclass
# B-56: bounds on _buckets, which is keyed by strings the caller chooses.
# 50k entries is a few MB at ~100 bytes each — far more than any real deployment's
# active attacker set, and small enough that filling it isn't a memory attack.
_MAX_BUCKETS = 50_000
# How often record_failure sweeps out spent entries. Cheap (one pass over a dict
# that the sweep itself keeps small) and off the request's critical path in the
# normal case, since a successful login records no failure at all.
_SWEEP_INTERVAL_SECONDS = 60.0
@dataclass
class _Bucket:
@@ -29,22 +38,65 @@ class RateLimiter:
base_delay: float = 2.0,
max_delay: float = 300.0,
decay_seconds: float = 900.0,
max_buckets: int = _MAX_BUCKETS,
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
) -> None:
self._threshold = threshold
self._base_delay = base_delay
self._max_delay = max_delay
self._decay_seconds = decay_seconds
self._max_buckets = max_buckets
self._sweep_interval_seconds = sweep_interval_seconds
self._buckets: dict[str, _Bucket] = {}
self._last_sweep_at = time.monotonic()
def _is_spent(self, bucket: _Bucket, now: float) -> bool:
"""Nothing left to remember: the lockout has expired *and* the failure count
would decay to zero on the next failure anyway. Dropping such a bucket is
indistinguishable from keeping it — which is what makes eviction safe."""
return bucket.locked_until <= now and now - bucket.last_failure_at > self._decay_seconds
def _prune(self, now: float) -> None:
"""B-56: the dict was keyed by attacker-chosen strings (any username, and via
B-54 any IP) and only ever grew — `decay_seconds` aged a bucket's counter but
never removed the entry, so hammering login with random usernames was an
unbounded memory leak.
Spent buckets go first, and they carry no information, so that alone keeps
the dict at the size of the genuinely active attack surface. The hard cap
below is the backstop for a burst faster than the sweep interval: it evicts
the entries closest to expiry, i.e. the ones whose loss buys an attacker the
least — never the freshest lockouts, which are the ones actually holding an
attack back."""
for key in [k for k, b in self._buckets.items() if self._is_spent(b, now)]:
del self._buckets[key]
self._last_sweep_at = now
excess = len(self._buckets) - self._max_buckets
if excess > 0:
by_expiry = sorted(
self._buckets.items(), key=lambda item: (item[1].locked_until, item[1].last_failure_at)
)
for key, _ in by_expiry[:excess]:
del self._buckets[key]
def retry_after(self, key: str) -> float:
bucket = self._buckets.get(key)
if bucket is None:
return 0.0
remaining = bucket.locked_until - time.monotonic()
now = time.monotonic()
if self._is_spent(bucket, now):
# Self-cleaning read path: a key that's merely being probed never
# accumulates an entry that outlives its own usefulness.
del self._buckets[key]
return 0.0
remaining = bucket.locked_until - now
return remaining if remaining > 0 else 0.0
def record_failure(self, key: str) -> None:
now = time.monotonic()
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._buckets) > self._max_buckets:
self._prune(now)
bucket = self._buckets.setdefault(key, _Bucket())
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
bucket.failures = 0
@@ -58,6 +110,84 @@ class RateLimiter:
self._buckets.pop(key, None)
class RollingQuota:
"""How many times a key may do something in a rolling window — as opposed to
RateLimiter above, which punishes *failures* with a growing delay.
B-58: registration was throttled with the failure limiter, and recorded a
"failure" on every attempt, successful ones included. Five legitimate signups
from one shared or NAT address locked the sixth real user out for up to 600s,
with the backoff doubling from there — while an attacker sidestepped the whole
thing through B-54. The intent (bound how many accounts one source can create)
is right; failure backoff is the wrong instrument for it, since nothing here is
a failed guess at a secret. A quota says exactly what is meant: this many
accounts per source per window, and the answer to the one over it is "not yet",
with an accurate wait rather than a punishment that grows.
"""
def __init__(
self,
limit: int,
window_seconds: float,
max_keys: int = _MAX_BUCKETS,
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
) -> None:
self._limit = limit
self._window_seconds = window_seconds
self._max_keys = max_keys
self._sweep_interval_seconds = sweep_interval_seconds
self._events: dict[str, list[float]] = {}
self._last_sweep_at = time.monotonic()
def _live_events(self, key: str, now: float) -> list[float]:
"""The key's events still inside the window, pruned in place."""
events = self._events.get(key)
if events is None:
return []
cutoff = now - self._window_seconds
while events and events[0] <= cutoff:
events.pop(0)
if not events:
del self._events[key]
return events
def _prune(self, now: float) -> None:
# Same bound as RateLimiter (B-56): the keys are caller-chosen, so the dict
# needs both a sweep and a hard cap. Eviction order is likewise "closest to
# leaving the window first" — dropping a key with room left in its quota
# changes nothing, dropping a full one hands out free accounts.
for key in list(self._events):
self._live_events(key, now)
self._last_sweep_at = now
excess = len(self._events) - self._max_keys
if excess > 0:
by_oldest = sorted(self._events.items(), key=lambda item: item[1][-1])
for key, _ in by_oldest[:excess]:
del self._events[key]
def retry_after(self, key: str) -> float:
"""Seconds until this key may act again — 0 while it is under quota."""
now = time.monotonic()
events = self._live_events(key, now)
if len(events) < self._limit:
return 0.0
return events[0] + self._window_seconds - now
def record(self, key: str) -> None:
"""Counts one *completed* action. Attempts that create nothing (a taken
username, a validation error) deliberately don't consume the quota — the
limit is on accounts that exist, not on requests."""
now = time.monotonic()
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._events) > self._max_keys:
self._prune(now)
self._events.setdefault(key, []).append(now)
_REGISTRATIONS_PER_IP = 5
_REGISTRATION_WINDOW_SECONDS = 3600.0
class AuthRateLimiters:
"""The three throttles B-33 needs, bundled so they can live on `app.state`
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
@@ -72,4 +202,9 @@ class AuthRateLimiters:
def __init__(self) -> None:
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
# B-58: a quota, not failure backoff — 5 accounts per IP per hour. The one
# over it waits only until the oldest of the five ages out, and a busy NAT
# is slowed rather than locked out for progressively longer.
self.register_ip = RollingQuota(
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
)
+37 -11
View File
@@ -1,13 +1,19 @@
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip as _client_ip
from app.api.errors import http_error
from app.auth.rate_limit import AuthRateLimiters
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
from app.auth.security import (
MIN_PASSWORD_LENGTH,
create_access_token,
hash_password_async,
verify_password_async,
)
from app.db.base import begin_immediate
from app.db.models import User
from app.db.session import get_session
from app.wallet.hd import derive_user_address
@@ -25,10 +31,11 @@ def _rate_limiters(request: Request) -> AuthRateLimiters:
# still catches that). The IP limiter's threshold is deliberately higher
# than the username one: a single account should lock out fast, but a
# shared/NAT IP hosting several genuine users shouldn't be punished for one
# of them mistyping a password a few times. Registration gets its own,
# coarser limiter, IP-only — no username exists yet to key on — mainly to
# bound how many accounts one IP can spin up (B-31), not to protect a
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
# of them mistyping a password a few times. Registration gets its own
# instrument entirely: a per-IP *quota* on accounts created (B-58), since
# bounding how many accounts one source can spin up is not the same problem
# as slowing down guesses at a secret, and failure backoff only punished the
# honest signups. Lives on app.state (see AuthRateLimiters) rather than a module
# global so each app instance gets its own, isolated throttle state.
if not hasattr(request.app.state, "auth_rate_limiters"):
request.app.state.auth_rate_limiters = AuthRateLimiters()
@@ -65,18 +72,36 @@ async def register(
) -> TokenResponse:
limiters = _rate_limiters(request)
ip_key = f"ip:{_client_ip(request)}"
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
# costs nothing to turn away. Recorded only once an account actually exists —
# see the successful path below.
retry_after = limiters.register_ip.retry_after(ip_key)
if retry_after > 0:
raise _rate_limited_error(retry_after)
limiters.register_ip.record_failure(ip_key)
existing = await session.scalar(select(User).where(User.username == body.username))
# B-57: case-insensitive, matching the unique index on lower(username) — and
# matching the throttle key below, which has always been lowercased.
existing = await session.scalar(
select(User).where(func.lower(User.username) == body.username.lower())
)
if existing is not None:
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
password_hash = hash_password(body.password)
password_hash = await hash_password_async(body.password)
for _ in range(_MAX_REGISTER_RETRIES):
# B-88: the max(derivation_index) read and the insert that uses it are one
# transaction under the write lock — with a plain BEGIN two concurrent
# registrations both read the same maximum and one of them failed on the
# unique index. A lock that cannot be had is a "try again", not a 500.
try:
await begin_immediate(session)
except OperationalError as exc:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"service_busy",
"the service is busy right now, nothing was sent — please try again",
) from exc
max_index = await session.scalar(select(func.max(User.derivation_index)))
next_index = 0 if max_index is None else max_index + 1
address = derive_user_address(next_index)
@@ -101,6 +126,7 @@ async def register(
) from exc
continue
await session.refresh(user)
limiters.register_ip.record(ip_key) # B-58: one account created, one slot used
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
return TokenResponse(
access_token=create_access_token(user.id, user.token_version), address=user.address
@@ -129,8 +155,8 @@ async def login(
if retry_after > 0:
raise _rate_limited_error(retry_after)
user = await session.scalar(select(User).where(User.username == body.username))
if user is None or not verify_password(body.password, user.password_hash):
user = await session.scalar(select(User).where(func.lower(User.username) == body.username.lower()))
if user is None or not await verify_password_async(body.password, user.password_hash):
# Same code path (and therefore the same response) whether the username
# doesn't exist or the password is wrong — no enumeration oracle here.
limiters.login.record_failure(username_key)
+23
View File
@@ -5,6 +5,7 @@ import logging
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from starlette.concurrency import run_in_threadpool
from app.config import settings
@@ -39,6 +40,28 @@ def verify_password(password: str, password_hash: str) -> bool:
return False
# --- B-55: the two Argon2 calls above must never run on the event loop ----------
# Argon2 is deliberately expensive — tens of milliseconds of CPU per call, by
# design. Called straight from an async handler that stalls the *whole* process
# for that long: every other request, and all six background tasks (scheduler,
# confirmation poller, RBF bumper, listener, both reconcilers). A burst of
# unauthenticated login attempts was therefore a cheap way to delay draws and
# confirmations, not just to slow down logins. The threadpool keeps the cost
# where it belongs — on a worker thread, with the loop free to run everything
# else meanwhile.
#
# The synchronous functions stay: they're what the wrappers call, and what tests
# and scripts (no running loop) use directly. Every async caller must use these.
async def hash_password_async(password: str) -> str:
return await run_in_threadpool(hash_password, password)
async def verify_password_async(password: str, password_hash: str) -> bool:
return await run_in_threadpool(verify_password, password, password_hash)
def create_access_token(user_id: int, token_version: int = 0) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
# "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued
+152 -17
View File
@@ -1,32 +1,68 @@
import logging
from datetime import datetime, timezone
from embit import script
from sqlalchemy import select
from sqlalchemy import func, select, update
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.tx.versions import delete_versions, record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
RECIPIENT_VOUT,
BuiltTransaction,
InsufficientFundsError,
Utxo,
build_signed_transaction,
)
logger = logging.getLogger(__name__)
class BetError(ApiError):
pass
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
class _RoundClosedDuringBuild(Exception):
"""Internal signal (B-53): the round stopped accepting bets while this one was
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
async def place_bet(
session: AsyncSession, client: ElectrumClient, user: User, network: TransactionNetwork | None = None
) -> RoundParticipant:
"""`network` (the ElectrumListener) is what verifies a refused broadcast and fans an
accepted one out (B-76); without it a refusal can never be proven, so it never
releases anything."""
# B-88 (DAT-74, BET-11): take SQLite's write lock before the first read, so the
# balance, the free UTXOs, the participants and the round's status read below are
# the state the phase-1 write lands on — a plain BEGIN only starts at the first
# write, leaving every read before it outside any transaction. Failing to get the
# lock within busy_timeout is not a conflict about this bet: nothing has been
# built or broadcast yet, so the player is simply asked to retry.
try:
await begin_immediate(session)
except OperationalError as exc:
raise _service_busy() from exc
round_ = await open_new_round_if_needed(session)
if round_ is None:
raise BetError("no_round_open", "no round open right now, please try again shortly")
config = await get_round_config(session)
if not round_accepts_bets(round_, config.round_duration_seconds):
if not round_accepts_bets(round_):
raise BetError("round_closing", "the current round is closing, please try again shortly")
already_playing = await session.scalar(
@@ -37,6 +73,31 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
if already_playing is not None:
raise BetError("already_betting", "you already have an active bet in the current round")
# B-52: "this round can always be paid out" is an invariant, and this is where it
# gets enforced — before any of this user's money moves. The payout has to spend
# one pool UTXO per bet, so a round that grew past what a single payout
# transaction may spend was unpayable: it stayed "paying_out" retrying forever,
# and because no new round may open while one is active, the whole lottery
# stopped. Refusing the bet costs the player one round of waiting; accepting it
# cost everyone the platform. Counted over every participant row, not just the
# confirmed ones: a bet that later fails frees a slot, so counting them all is
# the conservative direction.
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
)
if participant_count >= MAX_PARTICIPANTS_PER_ROUND:
raise BetError(
"round_full",
f"this round already has its maximum of {MAX_PARTICIPANTS_PER_ROUND} players, "
"wait for the next one",
max_participants=MAX_PARTICIPANTS_PER_ROUND,
)
if not round_.pool_address:
# B-80: open_new_round_if_needed always writes it; a row without one cannot
# take money that would then have nowhere verifiable to go.
raise RuntimeError(f"round {round_.id} has no pool address")
bet_amount = config.bet_amount_sats
unspent = (
@@ -56,7 +117,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
signing_key=user_key,
from_script=from_script,
utxos=utxos,
to_address=derive_pool_address(),
to_address=round_.pool_address, # B-80: this round's own pool address
amount_sats=bet_amount,
change_address=user.address,
fee_rate_sat_vb=config.fee_rate_sat_vb,
@@ -83,24 +144,90 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
user_id=user.id,
bet_amount_sats=built.recipient_sats,
bet_txid=built.txid,
bet_vout=RECIPIENT_VOUT, # B-73 (DAT-63)
broadcast_at=broadcast_at,
status="building",
)
session.add(participant)
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
session.add(pending)
await session.commit()
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
# B-53: the deadline check at the top of this function happened before the UTXO
# scan and the signing above, so re-check it here against the clock as it is now —
# a slow build must not sneak a bet past the round's deadline.
#
# And then the part the clock can't cover: a compare-and-set on the round's own
# row, in the *same* transaction as the participant insert. The scheduler flips
# "open" -> "closing" in a transaction of its own and only counts in-flight
# participants afterwards, so without this a bet could commit its "building" row
# in between and be paid into the pool while the round drew and paid out without
# it — money credited to no round, no participant and no refund path. The UPDATE
# takes SQLite's write lock, so the two transactions can no longer interleave:
# either this commits first and the scheduler's subsequent in-flight count sees
# the row, or the flip commits first and this matches zero rows and refuses the
# bet before anything is broadcast.
try:
if not round_accepts_bets(round_):
raise _RoundClosedDuringBuild
guard = await session.execute(
update(Round)
.where(Round.id == round_.id, Round.status == "open")
.values(status="open")
.execution_options(synchronize_session=False)
)
if guard.rowcount != 1:
raise _RoundClosedDuringBuild
await session.commit()
except _RoundClosedDuringBuild as exc:
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
# the UTXOs stay unspent and no participant row survives.
await session.rollback()
raise BetError(
"round_closing", "the current round is closing, please try again shortly"
) from exc
except OperationalError as exc:
# B-88: SQLite has no write-snapshot conflicts — an OperationalError here is
# the lock wait (busy_timeout) expiring, which says nothing about the round.
# Same rollback, but the honest answer is "busy", not "closing".
await session.rollback()
raise _service_busy() from exc
# --- Phase 2: broadcast, then promote both rows to their live state ---------
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
# The node refused it (fee too low, dust, mempool conflict, or simply an
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
# give the user a translatable failure instead of a bare 500 (B-07).
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
# B-76 (TX-04, TX-20): only a *verified* refusal undoes phase 1. A timeout, a
# dropped connection or a refusal nobody could verify leaves the tx possibly on the
# network, and releasing the coins then sent the bet's sats into the pool with no
# participant behind them — so those rows stay "building" for the reconciler.
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
if verdict.outcome is BroadcastOutcome.REFUSED:
# The network refused it (fee too low, dust, mempool conflict) and no server
# knows it — nothing is on-chain, so undo phase 1 completely and give the user
# a translatable failure instead of a bare 500 (B-07).
try:
await _release_failed_bet(session, participant, pending, built, user.id, verdict.reason)
except OperationalError:
# B-88: the database is busy, so the release could not be written. The
# rows stay "building" — the state phase 1 committed — and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error("could not release the failed bet %s: database busy, left to the reconciler", built.txid)
raise BetError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
logger.warning("bet %s: broadcast outcome uncertain (%s), left 'building' for the reconciler", built.txid, verdict.reason)
await session.refresh(participant) # reports the status the DB really holds: building
broadcaster.publish() # an in-flight bet counts towards the round's figures
return participant
# B-88: after the broadcast nothing may be undone. If the lock cannot be had the
# rows stay "building" (the tx is on the network) and the reconciler promotes
# them; the caller still gets the participant, with the status the DB holds.
try:
await begin_immediate(session)
except OperationalError:
await session.rollback()
logger.error("bet %s broadcast but the database is busy: left 'building' for the reconciler", built.txid)
await session.refresh(participant) # reports the status the DB really holds
return participant
participant.status = "broadcast"
pending.status = "pending"
await write_audit_log(
@@ -110,7 +237,6 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
user_id=user.id,
round_id=round_.id,
)
await session.commit()
await session.refresh(participant)
broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch
@@ -128,6 +254,7 @@ async def _release_failed_bet(
"""Undo phase 1 after a failed broadcast: free the UTXOs the build reserved, drop
the two rows, and restore the balance. Same shape as what the reconciler does for
a tx that turns out never to have made it onto the chain."""
await begin_immediate(session) # B-88
for spent in built.spent_utxos:
row = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
@@ -135,6 +262,7 @@ async def _release_failed_bet(
if row is not None:
row.spent_txid = None
await session.delete(participant)
await delete_versions(session, pending.id) # B-77 (DAT-38): the versions go with the row
await session.delete(pending)
await recompute_balance(session, user_id)
await write_audit_log(
@@ -151,6 +279,13 @@ async def _release_failed_bet(
broadcaster.publish()
def _service_busy() -> BetError:
return BetError(
"service_busy",
"the service is busy right now, nothing was sent — please try again",
)
def _pending_transaction(
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
) -> PendingTransaction:
+49
View File
@@ -1,3 +1,5 @@
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
@@ -5,6 +7,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# than being shipped by accident.
MIN_JWT_SECRET_LENGTH = 32
# B-84 (ELC-19, ELC-14): fallback servers needed besides the primary. With fewer than
# three servers, a corroboration (which never asks the active one) cannot have two
# independent answers — one server would decide, or none would.
MIN_QUORUM_FALLBACK_SERVERS = 2
MIN_QUORUM_RESPONSES = 2
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -21,6 +31,15 @@ class Settings(BaseSettings):
# connection, which makes a single hardcoded server the platform's biggest
# single point of failure. Parsed by electrum.client.parse_endpoints.
electrum_fallback_servers: str = ""
# B-84 (ELC-03): SHA-256 (hex) of the primary server's DER certificate. Empty = not
# pinned. Fallback servers carry their own `:pin=<sha256>` in the list above.
electrum_cert_pin: str = ""
# B-84 (ELC-19): development only. Lets the server start with fewer than
# MIN_QUORUM_FALLBACK_SERVERS fallback servers, and corroboration then follows the
# reduced rule (no other server: passes; otherwise a strict majority of however
# many answer, even one). Never for production: it is exactly the single-server
# trust the quorum exists to remove.
allow_no_quorum: bool = False
xprv_encryption_key: str = ""
master_key_path: str = "./master.xprv.enc"
@@ -79,3 +98,33 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
)
if problems:
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
def validate_quorum_servers(endpoints: list, config: Settings | None = None) -> None:
"""B-84 (ELC-19): refuse to serve with fewer than MIN_QUORUM_FALLBACK_SERVERS
fallback servers (`endpoints` is the deduplicated rotation: primary first, then the
fallbacks), unless ALLOW_NO_QUORUM=true. The old behaviour — an empty
ELECTRUM_FALLBACK_SERVERS, the .env.example default — silently made every
corroboration pass, i.e. one server decided the draw, the credits and the spends.
Called from the lifespan, after parse_endpoints, rather than from
validate_runtime_secrets: it needs the parsed list, and a Settings validator would
couple every import (tests included) to a populated .env.
"""
config = config or settings
fallbacks = len(endpoints) - 1
if fallbacks >= MIN_QUORUM_FALLBACK_SERVERS:
return
if config.allow_no_quorum:
logger.warning(
"ALLOW_NO_QUORUM=true: only %s fallback Electrum server(s) configured (%s needed) — "
"corroboration is reduced to the weaker rule. Development only, never in production.",
fallbacks,
MIN_QUORUM_FALLBACK_SERVERS,
)
return
raise ConfigError(
f"ELECTRUM_FALLBACK_SERVERS lists {fallbacks} usable fallback server(s) besides the primary, "
f"but at least {MIN_QUORUM_FALLBACK_SERVERS} are required so every corroboration has two independent "
"answers (set ALLOW_NO_QUORUM=true to override, development only)"
)
+58 -5
View File
@@ -1,16 +1,22 @@
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# How long a writer waits for a lock held by another writer before SQLite raises
# "database is locked" (B-39). A few seconds is enough to ride out this app's own
# five concurrent background tasks (scheduler, confirmation poller, RBF bumper,
# two reconcilers) plus HTTP handlers briefly overlapping a write.
# six concurrent background tasks (Electrum listener, scheduler, confirmation
# poller, RBF bumper, two reconcilers) plus HTTP handlers briefly overlapping a
# write.
_SQLITE_BUSY_TIMEOUT_MS = 5000
# Execution option read by the `begin` event below: which BEGIN a connection's next
# transaction opens with. Set through `begin_immediate`, never by hand.
_BEGIN_MODE_OPTION = "sqlite_begin_mode"
def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
"""Without WAL, SQLite's default (rollback-journal) mode lets a writer block
every reader for the duration of its transaction, and a second writer arriving
@@ -20,22 +26,69 @@ def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
busy_timeout gives a second writer a real window to wait for the first
instead of an instant `OperationalError`.
B-88: two more things, both about the code's transactions being what it
assumes. `synchronous=FULL`, because with WAL `NORMAL` can lose the last
commits on power loss — a registration, and with it a derivation index that
would then be handed to the next user. And real transaction control: pysqlite's
legacy mode emits `BEGIN` only at the first INSERT/UPDATE/DELETE, so every read
before it ran outside any transaction and the lock wait surfaced as `database is
locked` at an arbitrary later statement. `isolation_level = None` turns the
driver's implicit BEGIN off and the `begin` event emits it ourselves — a plain
`BEGIN` by default, `BEGIN IMMEDIATE` for a transaction opened through
`begin_immediate`, which takes the write lock *before* the first read so that
what a money-moving transaction reads is what it then writes on top of.
No-op for any dialect other than sqlite (e.g. a future PostgreSQL
DATABASE_URL), which neither needs nor understands these pragmas.
DATABASE_URL), which neither needs nor understands any of this.
"""
if target_engine.dialect.name != "sqlite":
return
@event.listens_for(target_engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
# Before the pragmas: journal_mode cannot change inside a transaction, and in
# legacy mode the driver may have one open.
dbapi_connection.isolation_level = None
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA synchronous=FULL") # B-88
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
finally:
cursor.close()
@event.listens_for(target_engine.sync_engine, "begin")
def _emit_begin(conn) -> None: # B-88
mode = conn.get_execution_options().get(_BEGIN_MODE_OPTION, "DEFERRED")
conn.exec_driver_sql("BEGIN IMMEDIATE" if mode == "IMMEDIATE" else "BEGIN")
async def begin_immediate(session: AsyncSession) -> None:
"""B-88: start the session's next transaction with `BEGIN IMMEDIATE`, i.e. take
SQLite's write lock now, before the first read.
Every transaction that moves money or fixes its state calls this first thing, so
that what it reads (balance, free UTXOs, participants, the round's status) cannot
change under it before its own writes, and so that the wait for the lock happens
here — at the very start, before anything has been broadcast — instead of at the
first write. Raises `sqlalchemy.exc.OperationalError` ("database is locked") when
the lock is not granted within `busy_timeout`; the caller decides whether that
means "refuse" (before a broadcast) or "leave it to the reconciler" (after).
A transaction the session already has open is committed first — a lock cannot be
upgraded in place, and the callers open the transaction with nothing but reads
behind them (typically the authentication lookup). Pending, unflushed changes
make that unsafe to do silently, so they are an error.
Harmless on a session whose engine did not go through `_register_sqlite_pragmas`
(the option is simply never read) and on a non-sqlite dialect.
"""
if session.new or session.dirty or session.deleted:
raise RuntimeError("begin_immediate called with unflushed changes on the session")
if session.in_transaction():
await session.commit()
await session.connection(execution_options={_BEGIN_MODE_OPTION: "IMMEDIATE"})
engine = create_async_engine(settings.database_url)
_register_sqlite_pragmas(engine)
+90 -2
View File
@@ -13,6 +13,16 @@ def utcnow() -> datetime:
class User(Base):
__tablename__ = "users"
# B-57: usernames are compared case-insensitively, and that has to be the
# database's job, not a convention the query layer remembers. "Bob" and "bob"
# used to be two accounts sharing one rate-limit bucket (each locking the other
# out) and, worse on a custodial system, a ready-made impersonation vector.
# A functional unique index rather than a normalized column: the name stays
# stored exactly as the user typed it, which is what /admin and the audit log
# display. The username pattern (auth/routes.py) is ASCII-only, so lower() is
# the whole of the normalization — no Unicode casefolding subtleties apply.
__table_args__ = (Index("ix_users_username_lower", text("lower(username)"), unique=True),)
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
@@ -72,6 +82,22 @@ class Round(Base):
status: Mapped[str] = mapped_column(String(16), default="open")
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(default=None)
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
# Read live from the config, a mid-round edit applied retroactively: lowering
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
# instantly, and raising it moved the closes_at every client was already
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
# The config row is now what the *next* round opens with; these are what this
# round runs by. cooldown_seconds is read off the round that just closed, so
# the gap it announced is the gap that's honoured.
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
# B-80 (DAT-60, RND-35): this round's own pool address, m/84'/746'/0'/2/<id>. The id
# only exists once the row is flushed, so the row is inserted with "" and the real
# address is written in the same transaction (rounds/service.py:open_new_round_if_needed)
# — "" is never visible outside it, and every consumer refuses it. Rounds opened before
# B-80 hold the reserve address (m/84'/746'/0'/1/0), their old shared pool.
pool_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
# (draw_waiting_since) measure how long a round has been waiting on a block,
@@ -85,20 +111,48 @@ class Round(Base):
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
# `server_seed` stays NULL until the reveal, in the transaction that stores the winner (or
# at the close of a round without participants), so a copy of the database alone never
# tells who will win. `participants_hash`, `draw_height` and each participant's
# `draw_position` are written when the round enters "drawing" and never change — what a
# resumed draw (B-81) reads instead of recomputing. `draw_confirmations` is the number of
# blocks the chain must have from `draw_height` before its hash is read, copied from the
# config at open.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
participants_hash: Mapped[str | None] = mapped_column(String(64), default=None)
draw_height: Mapped[int | None] = mapped_column(default=None)
class RoundParticipant(Base):
__tablename__ = "round_participants"
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
__table_args__ = (
UniqueConstraint("round_id", "user_id"),
# B-73 (DAT-66): one draw position per participant; NULLs (not yet in an ordered
# list) do not collide.
Index("ux_round_participants_round_draw_position", "round_id", "draw_position", unique=True),
)
id: Mapped[int] = mapped_column(primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
bet_txid: Mapped[str] = mapped_column(String(64))
# Ordering / tie-break field per spec: broadcast time, not confirmation time.
# B-73 (DAT-63): which output of the bet transaction pays the round's pool address —
# together with bet_txid the outpoint the draw list publishes and the payout spends.
bet_vout: Mapped[int] = mapped_column(default=0, server_default="0")
# Informational only since B-73 (DAT-25): the draw order is by bet_txid, which anyone
# can check, not by this server-side timestamp.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-66): 0-based position in the ordered draw list, written once on entering
# "drawing" for every confirmed participant.
draw_position: Mapped[int | None] = mapped_column(default=None)
status: Mapped[str] = mapped_column(String(16), default="broadcast")
@@ -123,12 +177,22 @@ class RoundConfig(Base):
# NOT gate the actual draw, which still waits for a real confirmed block for
# its entropy (rounds/scheduler.py) — that can take longer than this value.
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
# B-73 (DAT-62): blocks the chain must have from a round's draw_height before the hash
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
# applies from the next round.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
# Maintenance switch: when true, the round currently in progress still runs to
# completion (closes, draws, pays out the winner) but no new round is opened
# afterwards — see rounds/service.py:open_new_round_if_needed.
paused: Mapped[bool] = mapped_column(default=False)
# B-78 (DAT-69): why the lottery is paused — NULL exactly when `paused` is false.
# "operator" (POST /admin/pause) or an automatic reason the system wrote itself
# ("resurrected_tx" so far; "reorg" and "payout_blocked" belong to B-85 and B-82).
# An automatic reason is never overwritten by an operator pause, so the cause that
# needs a look is not lost; POST /admin/resume clears whichever it is.
paused_reason: Mapped[str | None] = mapped_column(String(16), default=None)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
@@ -171,9 +235,33 @@ class PendingTransaction(Base):
attempt_count: Mapped[int] = mapped_column(default=1)
# Why the reconciler gave up on this tx — operator-facing, only set on "failed".
failure_reason: Mapped[str | None] = mapped_column(String(128), default=None)
# B-78 (DAT-72): when the row went to "failed". A failed row stays watched for
# FAILED_WATCH_DAYS from here: a transaction given up for dead can still confirm.
failed_at: Mapped[datetime | None] = mapped_column(default=None)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class PendingTransactionTxid(Base):
"""Every version a PendingTransaction has ever had (B-77, DAT-70, DAT-71): the first
one, each RBF replacement and each candidate written before a bump is broadcast.
The network may confirm *any* of them — the original while a bump was in flight, a
replacement the process crashed before recording — so the poller and the reconciler
look for every txid of a row, not only `current_txid`. Rows are never modified or
deleted (except with the `bet` row `_release_failed_bet` removes); `txid` is unique
over the whole table, so a txid belongs to exactly one platform transaction.
`raw_hex` is empty only for a `replaced_by_txid` reconstructed by the migration,
whose raw transaction is no longer available."""
__tablename__ = "pending_transaction_txids"
id: Mapped[int] = mapped_column(primary_key=True)
pending_transaction_id: Mapped[int] = mapped_column(ForeignKey("pending_transactions.id"), index=True)
txid: Mapped[str] = mapped_column(String(64), unique=True)
raw_hex: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class Withdrawal(Base):
__tablename__ = "withdrawals"
+82 -1
View File
@@ -4,13 +4,43 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
from app.wallet.psbt_builder import DUST_LIMIT_SATS
logger = logging.getLogger(__name__)
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""The subset of `entries` that would actually credit something: confirmed
(height > 0, per the Electrum convention where <= 0 means mempool) and not
already recorded, and not dust (B-75: below DUST_LIMIT_SATS nothing is credited).
Split out from credit_confirmed_utxos so the caller
(electrum/listener.py:refresh_user) can corroborate each *new* outpoint
against the other configured servers before any of it is written (B-59) —
the mirror image of what B-29 already required before a balance may go
*down*. Only new ones: corroborating outpoints already credited would open a
connection to every other server on every refresh, for an answer that can no
longer change what we hold.
"""
existing_keys = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
).all()
}
return [
entry
for entry in entries
if entry["height"] > 0
and entry["value"] >= DUST_LIMIT_SATS # B-75 (DEP-01)
and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
]
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
@@ -19,7 +49,12 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
entries: [{"tx_hash": ..., "tx_pos": ..., "height": ..., "value": ...}, ...]
height <= 0 means unconfirmed (mempool) per the Electrum protocol convention —
skipped, since the spec requires 1 confirmation before crediting.
B-88: opens with BEGIN IMMEDIATE, so the "already recorded?" read and the inserts
it guards are one transaction — the unique (txid, vout) constraint stays the
backstop, not the mechanism.
"""
await begin_immediate(session)
existing_keys = {
(txid, vout)
for txid, vout in (
@@ -31,6 +66,11 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
for entry in entries:
if entry["height"] <= 0:
continue
if entry["value"] < DUST_LIMIT_SATS:
# B-75 (DEP-01): anyone can send dust to a public address; crediting it would
# make every later refresh of this user's address do more work, for coins
# that cost more to spend than they are worth.
continue
key = (entry["tx_hash"], entry["tx_pos"])
if key in existing_keys:
continue
@@ -63,15 +103,55 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
_EXTERNAL_SPEND_SENTINEL = "external-spend"
async def find_reinstate_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""Outpoints flagged as spent outside the platform that this refresh's `listunspent`
lists again, confirmed and for the amount we recorded — the *candidates* for
reinstatement (B-83, DEP-02, DEP-08). Read-only: like a new credit (B-59) and a
candidate external spend (B-29), a reinstatement moves a balance, so the caller
(electrum/listener.py:refresh_user) must have the other servers corroborate each
candidate before reinstate_reappeared_utxos writes it. An entry whose amount differs
from our record is not a candidate: it is not the coin we wrote off."""
flagged = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL)
)
).all()
amounts = {(row.txid, row.vout): row.amount_sats for row in flagged}
return [
entry
for entry in entries
if amounts.get((entry["tx_hash"], entry["tx_pos"])) == entry.get("value") and (entry.get("height") or 0) > 0
]
async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""The reverse of a mark applied by find_utxos_missing_from/
mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as
spent outside the platform reappears as unspent in a later listunspent, undo
the mark instead of leaving it permanent no matter what the chain says
afterwards. Cheap and purely DB-side — always safe to run on every refresh.
afterwards.
Persistence only, for entries the caller has **already corroborated** against the
other servers (B-83, ELC-25) — it used to run on the active server's word alone, so a
hostile one could put a spent coin back into a balance. Re-checks, under the write
lock, that each row still carries the mark before clearing it (DEP-08).
"""
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
# B-88: the lock is only worth taking when there is something to write, and this
# runs on every refresh — so look first (plain columns), then take it and read the
# rows again under it.
flagged = (
await session.execute(
select(UtxoEvent.txid, UtxoEvent.vout).where(
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
)
)
).all()
if not any((txid, vout) in current_keys for txid, vout in flagged):
return 0
await begin_immediate(session)
marked_rows = (
await session.scalars(
select(UtxoEvent).where(
@@ -152,6 +232,7 @@ async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_
may have resolved it (a legitimate platform spend, or a prior refresh) between
when the caller read the candidate list and finished corroborating it.
"""
await begin_immediate(session) # B-88: that re-check and the mark are one transaction
marked = 0
for utxo_id in utxo_ids:
row = await session.get(UtxoEvent, utxo_id)
+186 -24
View File
@@ -1,57 +1,104 @@
import asyncio
import hashlib
import itertools
import json
import logging
import re
import ssl
from typing import NamedTuple
logger = logging.getLogger(__name__)
class ElectrumEndpoint(NamedTuple):
"""One server to connect to. The listener rotates over a list of these so a
single unreachable or misbehaving server doesn't take the platform down —
every consumer of PLM chain data goes through this one connection."""
every consumer of PLM chain data goes through this one connection.
`pin` (B-84, ELC-03) is the SHA-256 of the server's DER certificate, in lower-case
hex; when set, a connection presenting any other certificate fails like an
unreachable server. Only meaningful with TLS."""
host: str
port: int
use_ssl: bool = True
pin: str | None = None
def __str__(self) -> str:
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}"
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}{' (pinned)' if self.pin else ''}"
_PIN_PATTERN = re.compile(r"[0-9a-f]{64}")
def normalize_pin(raw: str, entry: str) -> str:
"""A certificate pin as lower-case hex, or ValueError: 64 hexadecimal digits."""
pin = raw.strip().lower()
if not _PIN_PATTERN.fullmatch(pin):
raise ValueError(f"invalid certificate pin in Electrum server {entry!r}: expected 64 hex digits (sha256)")
return pin
def parse_endpoints(
primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str
primary_host: str,
primary_port: int,
primary_use_ssl: bool,
fallback_spec: str,
primary_pin: str = "",
) -> list[ElectrumEndpoint]:
"""Build the connection rotation: the primary server first, then whatever
ELECTRUM_FALLBACK_SERVERS lists.
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
case) or `host:port:notls`. Malformed entries raise ValueError rather than
being skipped: a typo in a fallback server is something to fix at startup,
not to discover during an outage, when the fallback is what's needed.
case), `host:port:notls`, `host:port:pin=<sha256>` (TLS with a pinned certificate,
B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=<sha256>`. A pin on a
plaintext entry is refused: there is no certificate to pin. `primary_pin` is the
same pin for the primary (ELECTRUM_CERT_PIN). Malformed entries raise ValueError
rather than being skipped: a typo in a fallback server is something to fix at
startup, not to discover during an outage, when the fallback is what's needed.
Duplicates are dropped, keeping first position.
"""
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)]
syntax = "expected host:port[:notls][:pin=<sha256>]"
pin = normalize_pin(primary_pin, "ELECTRUM_CERT_PIN") if primary_pin.strip() else None
if pin and not primary_use_ssl:
raise ValueError("ELECTRUM_CERT_PIN needs TLS: ELECTRUM_USE_SSL is off")
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl, pin)]
for raw in fallback_spec.split(","):
entry = raw.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) not in (2, 3):
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
if not 2 <= len(parts) <= 4:
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
host, port = parts[0].strip(), parts[1].strip()
if not host or not port.isdigit():
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
use_ssl = True
if len(parts) == 3:
flag = parts[2].strip().lower()
if flag not in ("ssl", "tls", "notls", "plain"):
pin = None
mode_seen = False
for extra in parts[2:]:
extra = extra.strip()
if extra.lower().startswith("pin="):
if pin is not None:
raise ValueError(f"invalid Electrum server {entry!r}: more than one pin")
pin = normalize_pin(extra[4:], entry)
continue
flag = extra.lower()
if mode_seen or flag not in ("ssl", "tls", "notls", "plain"):
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
mode_seen = True
use_ssl = flag in ("ssl", "tls")
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl))
if pin and not use_ssl:
raise ValueError(f"invalid Electrum server {entry!r}: a pin needs TLS")
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl, pin))
deduped: list[ElectrumEndpoint] = []
seen: set[tuple] = set()
for endpoint in endpoints:
if endpoint not in deduped:
key = (endpoint.host, endpoint.port, endpoint.use_ssl)
if key not in seen:
seen.add(key)
deduped.append(endpoint)
return deduped
@@ -64,11 +111,37 @@ def parse_endpoints(
# and the confirmation poller could stop polling permanently.
_REQUEST_TIMEOUT_SECONDS = 15
# Longest reply line the client accepts (B-75, ELC-20). asyncio's default StreamReader
# limit is 64 KiB, and a longer line used to raise out of the read loop and close the
# only connection: the pool address's get_history passed that after ~800 transactions
# (and a few hundred dust outputs sent to any public address do it on purpose), after
# which every confirmation poll dropped the connection and payouts never confirmed.
_MAX_LINE_BYTES = 16 * 1024 * 1024
_READ_CHUNK_BYTES = 64 * 1024
# How much of each end of a discarded line is kept to find the reply's `id` in, so the
# request it answered can fail at once instead of waiting out the 15s timeout.
_ID_SNIFF_BYTES = 256
_ID_PATTERN = re.compile(rb'"id"\s*:\s*(\d+)')
class ElectrumError(Exception):
pass
class ElectrumRpcError(ElectrumError):
"""The server *answered* with a JSON-RPC `error` object (B-76, TX-04). It is the
only failure that says anything about the request itself: a timeout, a dropped
connection or a write error (plain ElectrumError) leaves it unknown whether the
server ever acted on it. `args[0]` is the error object as the server sent it."""
@property
def message(self) -> str:
detail = self.args[0] if self.args else ""
if isinstance(detail, dict):
return str(detail.get("message", detail))
return str(detail)
class ElectrumClient:
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
@@ -83,10 +156,11 @@ class ElectrumClient:
notification queues nobody will ever fill again.
"""
def __init__(self, host: str, port: int, use_ssl: bool = True):
def __init__(self, host: str, port: int, use_ssl: bool = True, pin: str | None = None):
self.host = host
self.port = port
self.use_ssl = use_ssl
self.pin = pin.lower() if pin else None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._id_counter = itertools.count(1)
@@ -98,16 +172,36 @@ class ElectrumClient:
async def connect(self) -> None:
# Electrum servers commonly present self-signed certs; the protocol's trust
# model is server consensus, not TLS PKI, so we only use SSL for transport
# encryption and don't verify the certificate chain/hostname.
# encryption and don't verify the certificate chain/hostname. What can be
# asked instead is that the certificate be *the* one the operator pinned
# (B-84, ELC-03): without a pin, whoever sits on the path to a server takes
# its place, and only the quorum stands between that and the draw.
ssl_context = None
if self.use_ssl:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self._reader, self._writer = await asyncio.open_connection(self.host, self.port, ssl=ssl_context)
self._reader, self._writer = await asyncio.open_connection(
self.host, self.port, ssl=ssl_context, limit=_MAX_LINE_BYTES
)
if self.pin:
try:
self._check_pin()
except ElectrumError:
self._writer.close()
raise
self._read_task = asyncio.create_task(self._read_loop())
await self.request("server.version", ["plm-lottery", "1.4"])
def _check_pin(self) -> None:
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
ssl_object = self._writer.get_extra_info("ssl_object") if self._writer is not None else None
der = ssl_object.getpeercert(binary_form=True) if ssl_object is not None else None
if not der:
raise ElectrumError(f"{self.host}:{self.port} presented no certificate to check against the pin")
if hashlib.sha256(der).hexdigest() != self.pin:
raise ElectrumError(f"{self.host}:{self.port} certificate does not match the configured pin")
async def close(self) -> None:
self._closed.set()
if self._read_task is not None:
@@ -186,17 +280,58 @@ class ElectrumClient:
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
return await self.request("blockchain.transaction.get", [txid, verbose])
async def get_merkle(self, txid: str, height: int) -> dict:
"""The merkle branch proving `txid` is in the block at `height` (B-83, ELC-26)."""
return await self.request("blockchain.transaction.get_merkle", [txid, height])
async def get_block_header(self, height: int) -> str:
return await self.request("blockchain.block.header", [height])
async def get_block_headers(self, start_height: int, count: int) -> dict:
"""`count` consecutive headers from `start_height` (B-74, ELC-12), as the server's
{"count", "hex", "max"}: `hex` is the 80-byte headers concatenated, and the server
may return fewer than asked (never more than its `max`)."""
return await self.request("blockchain.block.headers", [start_height, count])
async def _read_loop(self) -> None:
"""Frame the stream into lines by hand rather than with StreamReader.readline:
readline raises on a line over the limit, and that exception used to end this
loop and with it the connection. An oversized line is now dropped up to its
newline (B-75, ELC-20) and the connection stays up."""
assert self._reader is not None
buffer = bytearray()
discarding = False
head = b""
tail = b""
try:
while True:
line = await self._reader.readline()
if not line:
chunk = await self._reader.read(_READ_CHUNK_BYTES)
if not chunk:
break
message = json.loads(line)
self._dispatch(message)
while chunk:
newline = chunk.find(b"\n")
piece = chunk if newline < 0 else chunk[:newline]
chunk = b"" if newline < 0 else chunk[newline + 1 :]
if discarding:
tail = (tail + piece)[-_ID_SNIFF_BYTES:]
else:
buffer += piece
if len(buffer) > _MAX_LINE_BYTES:
discarding = True
head = bytes(buffer[:_ID_SNIFF_BYTES])
tail = bytes(buffer[-_ID_SNIFF_BYTES:])
buffer.clear()
if newline >= 0:
if discarding:
self._fail_unreadable_reply(head + b" " + tail, "reply exceeds the line limit")
discarding = False
else:
self._handle_line(bytes(buffer))
buffer.clear()
if buffer and not discarding:
self._handle_line(bytes(buffer)) # a final line the server never terminated
finally:
# Whatever ended this loop — clean EOF, protocol error, TLS failure — the
# Whatever ended this loop — clean EOF, TLS failure — the
# connection is unusable from here on. Setting this is what makes the
# death observable to wait_closed(), and so to the listener's reconnect
# logic; without it the listener waited on notification queues nobody
@@ -208,6 +343,33 @@ class ElectrumClient:
future.set_exception(error)
self._pending.clear()
def _handle_line(self, line: bytes) -> None:
if not line.strip():
return
try:
message = json.loads(line)
except ValueError:
# A reply that is not JSON is a problem of that one request, not of the
# connection (B-75).
self._fail_unreadable_reply(line[:_ID_SNIFF_BYTES] + b" " + line[-_ID_SNIFF_BYTES:], "reply is not valid JSON")
return
if not isinstance(message, dict):
logger.warning("ignoring an Electrum message that is not a JSON object")
return
self._dispatch(message)
def _fail_unreadable_reply(self, sample: bytes, reason: str) -> None:
"""Fail the request an unusable reply belongs to, when its `id` can be read
from `sample` (the first and last bytes of the line); otherwise that request
fails by its own timeout (ELC-04)."""
logger.warning("dropping an unusable Electrum reply: %s", reason)
for match in _ID_PATTERN.finditer(sample):
future = self._pending.pop(int(match.group(1)), None)
if future is not None:
if not future.done():
future.set_exception(ElectrumError(reason))
return
def _dispatch(self, message: dict) -> None:
message_id = message.get("id")
if message_id is not None and message_id in self._pending:
@@ -215,7 +377,7 @@ class ElectrumClient:
if future.done():
return
if message.get("error"):
future.set_exception(ElectrumError(message["error"]))
future.set_exception(ElectrumRpcError(message["error"]))
else:
future.set_result(message.get("result"))
elif "method" in message:
+666 -71
View File
@@ -1,18 +1,28 @@
import asyncio
import logging
import time
from collections import Counter
from collections.abc import Callable
from embit.transaction import Transaction
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import User
from app.deposits.service import (
credit_confirmed_utxos,
find_new_credit_candidates,
find_reinstate_candidates,
find_utxos_missing_from,
mark_utxos_spent_externally,
reinstate_reappeared_utxos,
)
from app.electrum.client import ElectrumClient, ElectrumEndpoint
from app.config import MIN_QUORUM_RESPONSES
from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError
from app.electrum.merkle import proof_matches_header
from app.electrum.quorum import ServerAnswers
from app.electrum.scripthash import address_to_scripthash
from app.rounds.draw import (
HeaderValidationError,
@@ -21,6 +31,7 @@ from app.rounds.draw import (
header_prev_hash,
)
from app.rounds.events import broadcaster
from app.rounds.pause import pause_automatically
logger = logging.getLogger(__name__)
@@ -40,6 +51,31 @@ _CORROBORATION_TIMEOUT_SECONDS = 10
# of simultaneous in-flight requests against the one active connection.
_RESUBSCRIBE_CONCURRENCY = 20
# How many of the latest accepted headers the listener keeps (B-74/B-85, ELC-22): what lets
# it recognise a reorganisation and walk back to its fork point instead of mistaking it for
# a hostile server. Also the deepest fork it will follow.
_HEADER_WINDOW = 10
# B-86 (ELC-21): a connected server whose last accepted header is older than this is asked
# about by comparing its tip with the other servers' at every ping. Three block times of
# 120s: late enough that an ordinary slow stretch of the chain does not trigger the check.
_STALL_SECONDS = 360
# The clock the stall check reads — a module attribute so a test can move time.
_monotonic = time.monotonic
# A reorganisation at least this deep pauses the lottery (B-85, ELC-24): one block is
# ordinary on any chain, two or more is worth a look before a new round opens.
_REORG_PAUSE_DEPTH = 2
# `blockchain.block.headers` serves at most this many headers per request (ELC-12).
_MAX_HEADERS_PER_REQUEST = 2016
class ServerStalledError(Exception):
"""The active server stopped following the chain while others report a higher tip
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
@@ -62,10 +98,13 @@ class ElectrumListener:
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
session_factory: async_sessionmaker,
endpoints: list[ElectrumEndpoint] | None = None,
allow_no_quorum: bool = False,
):
self._client_factory = client_factory
self._session_factory = session_factory
self._endpoints = list(endpoints or [])
# B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority.
self._allow_no_quorum = allow_no_quorum
self._endpoint_index = 0
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
@@ -75,6 +114,11 @@ class ElectrumListener:
self._background_tasks: set[asyncio.Task] = set()
self.tip_height: int = 0
self.tip_header_hex: str | None = None
# B-74 (ELC-22): height -> header of the last _HEADER_WINDOW accepted headers, tip
# included. Survives reconnections, not restarts.
self._window: dict[int, str] = {}
# B-86: when the last header was accepted (monotonic seconds), None until one is.
self._last_header_at: float | None = None
self.client: ElectrumClient | None = None
@property
@@ -159,12 +203,31 @@ class ElectrumListener:
reset its backoff), False if it never got that far."""
client = self._client_factory(endpoint)
await client.connect()
self.client = client
logger.info("Electrum connected to %s", endpoint)
try:
header = await client.subscribe_headers()
self._apply_header(header)
await self._apply_header(header, client)
if self.tip_header_hex is None:
# B-64: the header was unusable (no hex) and we have never had a tip,
# so publishing this client would hand every consumer a connection
# whose chain position is unknown — B-63 all over again. A server at
# or behind a tip we already know is fine and doesn't come through
# here: the point is only that *some* tip is established.
raise HeaderValidationError(
f"{endpoint} announced an unusable initial header ({header!r}) and no tip is known"
)
# B-63: published only now, never before the first header has been
# applied. `self.client is not None` is what every consumer treats as
# "the chain is reachable" — including RoundScheduler._tick, which then
# reads tip_height as the baseline a draw must find a *later* block than.
# Assigning it before this round-trip left a window where the connection
# looked alive while tip_height was still 0, so a round closing inside it
# recorded a baseline of 0 and the very first header we learned — the
# current tip, a block mined *before* the round closed, with a hash
# already public while bets were still open — satisfied
# `tip_height > tip_at_close` and seeded the draw.
self.client = client
headers_queue = client.notifications("blockchain.headers.subscribe")
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
@@ -173,7 +236,7 @@ class ElectrumListener:
# keepalive are what make the drop observable. Whichever finishes first
# ends the session and sends run() around to the next server.
tasks = [
asyncio.create_task(self._consume_headers(headers_queue)),
asyncio.create_task(self._consume_headers(headers_queue, client)),
asyncio.create_task(self._consume_scripthash(scripthash_queue)),
asyncio.create_task(self._keepalive(client)),
asyncio.create_task(client.wait_closed()),
@@ -219,6 +282,29 @@ class ElectrumListener:
while True:
await asyncio.sleep(_PING_INTERVAL_SECONDS)
await client.ping() # raises (and so ends the session) on timeout or a dead socket
await self._check_not_stalled()
async def _check_not_stalled(self) -> None:
"""B-86 (ELC-21): `server.ping` keeps succeeding on a server that has stopped
following the chain, so liveness alone never replaced one. When the last accepted
header is older than _STALL_SECONDS, ask every *other* server for its tip
(`blockchain.headers.subscribe`, with ELC-14's timeouts): if any reports a higher one
than we know, this session ends and the listener moves to the next server. If none
does, the network simply produced no block and the session goes on."""
if self._last_header_at is None or _monotonic() - self._last_header_at <= _STALL_SECONDS:
return
async def _tip_height(client: ElectrumClient) -> int | None:
header = await client.subscribe_headers()
height = header.get("height") if isinstance(header, dict) else None
return height if isinstance(height, int) else None
heights = [h for h in await self._ask_other_servers(_tip_height) if h is not None]
if any(height > self.tip_height for height in heights):
raise ServerStalledError(
f"no header accepted for {_monotonic() - self._last_header_at:.0f}s while another server "
f"reports tip {max(heights)} (ours: {self.tip_height})"
)
async def _subscribe_all_users(self) -> None:
"""B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at
@@ -248,29 +334,47 @@ class ElectrumListener:
await self.client.subscribe_scripthash(scripthash)
await self.refresh_user(user_id, scripthash)
def _apply_header(self, header: dict) -> None:
"""Record a new chain tip, refusing to move backwards.
async def _apply_header(self, header: dict, client: ElectrumClient | None = None) -> None:
"""Record a new chain tip after checking it (ELC-08 … ELC-13, ELC-22, ELC-23).
Full reorg handling is out of scope for v1 by explicit design decision, but
the tip must never regress: `_wait_for_next_block` waits for
`tip_height > tip_at_close`, so a lower height would silently add a block to
the draw's wait. height and hex are applied together or not at all —
applying a losing header's hex would leave tip_height and tip_header_hex
describing different blocks, and that hex is the draw's entropy source.
The tip never regresses, and `tip_height`/`tip_header_hex` always describe the
same block: they are applied together or not at all, since that hex is where a
draw's entropy comes from. A header that is ignored (no hex, below the tip, the
tip itself again) changes nothing and ends nothing.
Two validation checks guard against a hostile or MITM'd server simply
fabricating a header (B-28), since that header is the draw's sole source of
entropy: it must satisfy the difficulty target it claims for itself, and —
when it's a direct single-block advance from our own current tip, the only
case we can check without a full header chain — it must chain from that
tip's hash. Either failure raises HeaderValidationError rather than
silently ignoring the header, which (via _consume_headers/_run_once) ends
this session the same way a dropped connection would, so run() rotates to
the next configured server instead of continuing to trust this one.
Everything else must be proven, because a forged header steers a draw (B-28):
it must meet the target it claims *and* that target must be no easier than
PLM's powLimit (B-74, ELC-11), and every advance must be linked to the tip we
hold (ELC-12) — a single block by its `prev_hash`, a jump of several by
downloading the headers in between from `client` and checking each. A header that
fails these raises HeaderValidationError, which (via _consume_headers/_run_once)
ends this session like a dropped connection and rotates to the next server.
A header that merely does not *join* our tip is not hostile in itself: it is a
reorganisation, and is followed back to its fork point inside the last
_HEADER_WINDOW headers (B-74, ELC-23) instead of ending the session on every
server until the next block arrives. `client` is the active connection the
missing headers are fetched from.
"""
height = header["height"]
header_hex = header.get("hex")
if height < self.tip_height:
if not header_hex:
# Nothing to validate and nothing to draw from — and applying the height
# alone would break exactly the pairing this function exists to keep:
# tip_height would describe a block tip_header_hex doesn't (and on a
# session's first header, would publish a client whose tip is unknown,
# which is B-63). Ignored rather than fatal: a server that only ever
# pushed heights would freeze the draw — visibly, via B-36's
# draw_stalled — instead of costing us the one connection that also
# credits deposits and broadcasts transactions. _run_once separately
# refuses to publish a client while the tip is still unknown.
logger.warning(
"ignoring Electrum header at height %s: no header hex to validate or to draw from", height
)
return
if self.tip_header_hex is not None and height < self.tip_height:
logger.warning(
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
height,
@@ -278,22 +382,159 @@ class ElectrumListener:
)
return
if header_hex:
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError(
f"header at height {height} does not satisfy its own claimed difficulty target"
)
if (
self.tip_header_hex
and height == self.tip_height + 1
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
):
raise HeaderValidationError(
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
)
if self.tip_header_hex is not None and height == self.tip_height:
if header_hex == self.tip_header_hex:
return # the same block announced again
# B-85 (ELC-09): another block at the height we hold — a reorg at the tip. It is
# followed like any other fork (it must bring the work and the chain to show for
# it), not ignored: ignoring it left us on an orphan for as long as the tip
# stood, and a draw seeded from it would have been refused by the quorum.
await self._follow_reorg(height, header_hex, client)
return
self.tip_height = height
self.tip_header_hex = header_hex
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError(
f"header at height {height} is not a valid proof of work "
"(its hash misses the target it claims, or the target is easier than the powLimit)"
)
if self.tip_header_hex is None:
await self._adopt_first_header(height, header_hex, client)
return
tip_hash = header_hex_to_block_hash(self.tip_header_hex)
if height == self.tip_height + 1:
if header_prev_hash(header_hex) == tip_hash:
self._extend_window([(height, header_hex)])
return
await self._follow_reorg(height, header_hex, client)
return
# A jump of several blocks (a reconnect, a server that was ahead of us): link it to
# the tip by fetching what lies in between — an unlinked jump used to be accepted
# on the strength of its own difficulty alone.
if client is None:
raise HeaderValidationError(f"cannot link the header at height {height} to the tip: no connection to fetch from")
between = await self._fetch_headers(client, self.tip_height + 1, height - self.tip_height - 1)
chain = [*between, header_hex]
self._check_chained(chain, header_prev_hash(chain[0])) # internal linkage and work only
if header_prev_hash(chain[0]) != tip_hash:
await self._follow_reorg(height, header_hex, client)
return
self._extend_window([(self.tip_height + 1 + i, h) for i, h in enumerate(chain)])
async def _fetch_headers(self, client: ElectrumClient, start: int, count: int) -> list[str]:
"""`count` headers from `start` (blockchain.block.headers, at most
_MAX_HEADERS_PER_REQUEST per request). A server that returns fewer than it must,
or something that is not whole 80-byte headers, ends the session."""
headers: list[str] = []
while len(headers) < count:
wanted = min(count - len(headers), _MAX_HEADERS_PER_REQUEST)
reply = await client.get_block_headers(start + len(headers), wanted)
got = reply.get("count") if isinstance(reply, dict) else None
raw = reply.get("hex") if isinstance(reply, dict) else None
if not isinstance(got, int) or not isinstance(raw, str) or not 0 < got <= wanted or len(raw) != got * 160:
raise HeaderValidationError(
f"the server returned an unusable reply for headers {start + len(headers)}..+{wanted}"
)
headers.extend(raw[i : i + 160] for i in range(0, len(raw), 160))
return headers
@staticmethod
def _check_chained(headers: list[str], prev_hash: str) -> None:
"""Every header carries valid work and each one builds on the one before it, the
first on `prev_hash` (ELC-11, ELC-12). Raises HeaderValidationError."""
for header_hex in headers:
if not header_meets_its_own_target(header_hex):
raise HeaderValidationError("a downloaded header is not a valid proof of work")
if header_prev_hash(header_hex) != prev_hash:
raise HeaderValidationError("the downloaded headers are not a connected chain")
prev_hash = header_hex_to_block_hash(header_hex)
def _extend_window(self, entries: list[tuple[int, str]]) -> None:
"""Make `entries` (consecutive heights, the last the new tip) part of the window,
replacing anything at or above their first height, and move the tip with it."""
first = entries[0][0]
for stale in [h for h in self._window if h >= first]:
del self._window[stale]
self._window.update(entries)
for old in sorted(self._window)[:-_HEADER_WINDOW]:
del self._window[old]
self.tip_height, self.tip_header_hex = entries[-1]
self._last_header_at = _monotonic()
async def _adopt_first_header(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
"""The process's first header has nothing to join, so it is taken on its work alone
— and the (up to) 9 headers before it are downloaded and must chain into it,
which is what the window of ELC-22 is made of."""
entries: list[tuple[int, str]] = []
if client is not None and height > 0:
count = min(_HEADER_WINDOW - 1, height)
before = await self._fetch_headers(client, height - count, count)
first_prev = header_prev_hash(before[0])
self._check_chained(before, first_prev)
if header_prev_hash(header_hex) != header_hex_to_block_hash(before[-1]):
raise HeaderValidationError(f"the header at height {height} does not chain from the headers before it")
entries = [(height - count + i, h) for i, h in enumerate(before)]
self._window.clear()
self._extend_window([*entries, (height, header_hex)])
async def _follow_reorg(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
"""The header at `height` does not build on our tip: find where the server's chain
and ours part (the highest window height at which the server's hash is the same as
ours) and, if that is inside the window, adopt the server's branch from there on
(ELC-23). A fork older than the window, or a branch that is not a connected chain
of valid work ending at the announced header, raises HeaderValidationError."""
if client is None:
raise HeaderValidationError(f"the header at height {height} does not join the tip and there is no connection to follow it")
lowest = min(self._window)
ours = [self._window[h] for h in range(lowest, self.tip_height + 1)]
theirs = await self._fetch_headers(client, lowest, len(ours))
fork_height = None
for offset in range(len(ours) - 1, -1, -1):
if header_hex_to_block_hash(theirs[offset]) == header_hex_to_block_hash(ours[offset]):
fork_height = lowest + offset
break
if fork_height is None:
raise HeaderValidationError(
f"the header at height {height} forks more than {_HEADER_WINDOW} blocks below our tip {self.tip_height}"
)
branch = await self._fetch_headers(client, fork_height + 1, height - fork_height)
if branch[-1] != header_hex:
raise HeaderValidationError(f"the server's branch does not end at the header it announced at height {height}")
self._check_chained(branch, header_hex_to_block_hash(self._window[fork_height]))
old_height, old_hex = self.tip_height, self.tip_header_hex
# Recorded *before* the window moves: if the write fails the session ends, the next
# server announces the same fork against an unchanged window, and the event is
# written then — a reorg is never followed without a trace.
await self._record_reorg(
depth=old_height - fork_height,
fork_height=fork_height,
old_tip_height=old_height,
old_tip_hash=header_hex_to_block_hash(old_hex),
new_tip_height=height,
new_tip_hash=header_hex_to_block_hash(header_hex),
)
self._extend_window([(fork_height + 1 + i, h) for i, h in enumerate(branch)])
async def _record_reorg(self, **payload) -> None:
"""B-85 (ELC-24, AUD-38): the audit row of a followed reorganisation; a depth of 2
or more also pauses the lottery (`paused_reason = 'reorg'`), in the same transaction
as the row and as the `lottery_paused` one. Depth 1 is only observed: a one-block
reorganisation is ordinary on any chain and touches no deposit."""
logger.warning(
"followed a chain reorganisation of depth %s: fork at height %s, tip %s -> %s",
payload["depth"],
payload["fork_height"],
payload["old_tip_height"],
payload["new_tip_height"],
)
async with self._session_factory() as session:
await begin_immediate(session)
await write_audit_log(session, "chain_reorg_detected", payload)
if payload["depth"] >= _REORG_PAUSE_DEPTH:
await pause_automatically(session, "reorg")
await session.commit()
async def _corroborate_majority(
self,
@@ -301,60 +542,257 @@ class ElectrumListener:
agrees: Callable[[object], bool],
description: str,
) -> bool:
"""Shared quorum logic behind corroborate_header (B-28) and
corroborate_utxo_spent (B-29): ask every *other* configured server (never
"""Shared quorum logic behind
corroborate_utxo_spent (B-29), credits and reinstatements: ask every *other* configured server (never
the currently active one — that's exactly what a hostile server or a MITM
would control) and require a strict majority of the ones that actually
answer to agree, via `agrees`, with what our own connection reported.
Returns True with no other servers configured — nothing to corroborate
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
(see CLAUDE.md). Returns False (never silently "passes") if none of the
others could be reached, since an unreachable network proves nothing
either way.
The quorum is strict (B-84, ELC-14): it passes only when **at least
MIN_QUORUM_RESPONSES** of the other servers answered *and* a strict majority of
those that answered agree. Fewer answers — one reachable server, or none, or no
other server configured at all — fail, since a lone voice decides nothing
independently and an unreachable network proves nothing. Startup already refuses
to run with fewer than two fallbacks (config.validate_quorum_servers), so with a
sound configuration "too few answered" means servers are down, and the
corroboration simply fails (a delay, never a loss — the callers retry).
Only with ALLOW_NO_QUORUM=true (development) does the reduced rule apply: no
other server configured passes, and otherwise a strict majority of whoever
answered decides, even a single answer.
"""
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
if not others:
if not others and self._allow_no_quorum:
return True
if not others:
logger.warning("could not corroborate %s: no other Electrum server is configured", description)
return False
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
responded = [result for result in results if result is not None]
if not responded:
minimum = 1 if self._allow_no_quorum else MIN_QUORUM_RESPONSES
if len(responded) < minimum:
logger.warning(
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
"could not corroborate %s: %s of %s other configured server(s) answered, %s needed",
description,
len(responded),
len(others),
minimum,
)
return False
agreements = sum(1 for result in responded if agrees(result))
return agreements * 2 > len(responded)
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
"""B-28: is `expected_hash` — the header our own active connection
reported for `height` — corroborated by other configured servers before
the draw (rounds/scheduler.py:_wait_for_next_block) treats it as
trustworthy entropy? Without this, a single hostile server (or a MITM on
the one active connection) can single-handedly decide who wins every
round; this raises the bar to controlling a majority of the configured
servers. See _corroborate_majority for the shared quorum logic.
"""
# --- Asking every server (B-76, TX-04, TX-21) ---------------------------------
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
def _other_endpoints(self) -> list[ElectrumEndpoint]:
return [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
def _minimum_other_answers(self) -> int:
"""How many *other* servers must answer before their silence or agreement
counts as the network's: MIN_QUORUM_RESPONSES, or the reduced development rule
of ALLOW_NO_QUORUM (one answer, none if no other server is configured)."""
if not self._allow_no_quorum:
return MIN_QUORUM_RESPONSES
return 1 if self._other_endpoints() else 0
async def _ask_other_servers(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> list:
"""`ask(client)` on a fresh connection to every endpoint but the active one, in
parallel, each bounded by _CORROBORATION_TIMEOUT_SECONDS; a server that cannot be
reached or does not answer in time yields None. `ask` itself decides what an
answer is (it may return None for an unusable one)."""
async def _one(endpoint: ElectrumEndpoint):
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
result = await asyncio.wait_for(
client.request("blockchain.block.header", [height]),
timeout=_CORROBORATION_TIMEOUT_SECONDS,
)
if not isinstance(result, str):
return None
return header_hex_to_block_hash(result)
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
except Exception:
return None
finally:
await client.close()
return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header")
return list(await asyncio.gather(*(_one(endpoint) for endpoint in self._other_endpoints())))
async def _ask_active_server(self, ask: Callable[[ElectrumClient], "asyncio.Future"]):
client = self.client
if client is None:
return None
try:
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
except Exception:
return None
async def transaction_known(self, txid: str) -> bool | None:
"""Does the network know `txid`? Asked of the active server and every other
configured one with `blockchain.transaction.get` (B-76, TX-04):
* True — at least one server returns the transaction: it is on the network,
whatever the server that refused it said;
* False — every server that answered says it does not know it, and the active
server and the minimum number of the others (see _minimum_other_answers) are
among them: nobody has it;
* None — too few answers to tell. Nothing may be released on this.
"""
async def _ask(client: ElectrumClient) -> bool | None:
try:
raw = await client.get_transaction(txid, False)
except ElectrumRpcError:
return False # an explicit "no such transaction"
except Exception:
return None
try:
if isinstance(raw, str) and Transaction.parse(bytes.fromhex(raw)).txid().hex() == txid:
return True
except Exception:
pass
return None # a reply that is not this transaction proves nothing either way
active = await self._ask_active_server(_ask)
others = await self._ask_other_servers(_ask)
if active is True or any(answer is True for answer in others):
return True
answered_others = [answer for answer in others if answer is not None]
if active is False and len(answered_others) >= self._minimum_other_answers():
return False
return None
def fan_out_transaction(self, raw_tx_hex: str) -> None:
"""Send an accepted transaction to every other configured server too (B-76,
TX-21): a second route onto the network in case the active server's own relay
is weak. Best effort and off the caller's path — a failure is only logged."""
async def _send() -> None:
async def _broadcast(client: ElectrumClient) -> str:
return await client.broadcast(raw_tx_hex)
results = await self._ask_other_servers(_broadcast)
logger.info(
"fanned a transaction out to %s other server(s), %s answered", len(results), sum(r is not None for r in results)
)
task = asyncio.create_task(_send())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# --- What the network knows of a transaction (B-78, TX-15, TX-17) -------------------
async def _ask_everyone(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> ServerAnswers:
active, others = await asyncio.gather(self._ask_active_server(ask), self._ask_other_servers(ask))
return ServerAnswers(active, tuple(others), self._minimum_other_answers())
async def get_histories(self, scripthash: str) -> ServerAnswers:
"""`blockchain.scripthash.get_history` of `scripthash` from the active server and
every other one: each answer is the list of {"tx_hash", "height"} entries."""
async def _ask(client: ElectrumClient) -> list | None:
try:
history = await client.get_history(scripthash)
except Exception:
return None
return history if isinstance(history, list) else None
return await self._ask_everyone(_ask)
async def rebroadcast_everywhere(self, raw_tx_hex: str) -> ServerAnswers:
"""Send `raw_tx_hex` to the active server and every other one and report what each
said: ("accepted", "") or ("refused", <its message>) — an explicit JSON-RPC error —
and None for a server that gave no answer at all (TX-17). A tx the servers already
have comes back as a refusal with an "already known" message; the caller reads it."""
async def _ask(client: ElectrumClient) -> tuple | None:
try:
await client.broadcast(raw_tx_hex)
except ElectrumRpcError as exc:
return ("refused", exc.message)
except Exception:
return None
return ("accepted", "")
return await self._ask_everyone(_ask)
async def confirmed_spender(
self, scripthash: str, own_txids: set[str], outpoints: set[tuple[str, int]]
) -> str | None:
"""The txid of a transaction, none of the row's own versions, that spends one of
`outpoints` and is **in a block** — the first proof by which a row may be given
up (TX-17): its inputs went elsewhere. Asked of the *other* servers only (the
active one is what a hostile peer would control) and returned only when at least
the quorum minimum answered and a strict majority of the answerers name the same
txid. Each server looks through the confirmed history of the address the row
spends from.
"""
async def _ask(client: ElectrumClient) -> str | None:
history = await client.get_history(scripthash)
for entry in history:
txid = entry.get("tx_hash")
if txid in own_txids or (entry.get("height") or 0) <= 0:
continue
raw = await client.get_transaction(txid, False)
tx = Transaction.parse(bytes.fromhex(raw))
if tx.txid().hex() != txid:
continue
if any((vin.txid.hex(), vin.vout) in outpoints for vin in tx.vin):
return txid
return "" # looked everywhere: no conflicting transaction
answers = await self._ask_other_servers(_ask)
answered = [answer for answer in answers if answer is not None]
if len(answered) < max(self._minimum_other_answers(), 1):
return None
counts = Counter(answer for answer in answered if answer)
if not counts:
return None
txid, votes = counts.most_common(1)[0]
return txid if votes * 2 > len(answered) else None
async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]:
"""B-73 (RND-16, ELC-15): the hash of the block at exactly `height` — the draw's
entropy — as the *quorum* agrees on it, and what the active server said about it.
The header at that height (`blockchain.block.header`) is asked of every configured
server, the active one included, each over its own connection and within ELC-14's
timeouts. A header only counts if it is 80 bytes of valid work (ELC-11). The hash is
returned only when at least the quorum minimum of the *other* servers answered and a
strict majority of everybody who answered names the same one; otherwise the first
element is None and the caller asks again for **the same height** — the draw never
moves to a different block, so whoever relays blocks cannot pick one. The second
element is the active server's own answer (None if it gave none), for the audit row
written on a disagreement.
The old rule asked only the *other* servers to confirm the block the active one had
announced first after the close, which still let the active server choose which real
block seeded the draw simply by never announcing another.
"""
async def _ask(client: ElectrumClient) -> str | None:
header = await client.get_block_header(height)
if not isinstance(header, str) or not header_meets_its_own_target(header):
return None
return header_hex_to_block_hash(header)
answers = await self._ask_everyone(_ask)
reported = answers.active
if len(answers.answered_others) < self._minimum_other_answers():
logger.warning(
"block %s: %s of the other servers answered, %s needed", height, len(answers.answered_others),
self._minimum_other_answers(),
)
return None, reported
votes = Counter(answers.all)
if not votes:
return None, reported
block_hash, count = votes.most_common(1)[0]
if count * 2 <= len(answers.all):
return None, reported
if reported is not None and reported != block_hash:
logger.warning(
"block %s: the active server reported %s but the other servers agree on %s", height, reported, block_hash
)
return block_hash, reported
async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool:
"""B-29: before deposits/service.py's find_utxos_missing_from candidates
@@ -381,11 +819,125 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
async def _consume_headers(self, queue: asyncio.Queue) -> None:
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
way. A candidate external spend could not reduce a balance without a
quorum, but `value` and `height` for a *credit* were taken from the single
active connection and written straight to the DB — so one hostile or broken
server could inflate a user's displayed balance with outpoints that don't
exist. That never spends anyone else's coins (a bet or withdrawal built on
a phantom UTXO is refused at broadcast and rolled back), but it wedges the
balance display and burns build attempts, and on a custodial platform a
balance that isn't real is a support incident either way.
Agreement means: this server also reports the outpoint as unspent, for the
same amount, and considers it confirmed. The height itself is not compared —
a server still catching up reports the entry at height 0 and simply doesn't
agree, which is the same answer, while for a genuinely confirmed outpoint
two honest servers cannot disagree on the height anyway.
A failure here delays a credit, it never loses one: the next scripthash
notification or DepositReconciler sweep (300s) retries it, and a deposit is
only credited once the quorum agrees.
"""
return await self._corroborate_unspent_confirmed(
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
)
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
"""B-83 (ELC-25, DEP-08): the same quorum as a credit, for an outpoint that was
written off as spent outside the platform and has reappeared among the unspent.
Balances move in both directions and both need the same agreement: reinstating
on the active server's word alone let a hostile one put a spent coin back into a
balance."""
return await self._corroborate_unspent_confirmed(
scripthash, txid, vout, value, f"reinstatement of {value} sats at {txid}:{vout}"
)
async def _corroborate_unspent_confirmed(
self, scripthash: str, txid: str, vout: int, value: int, description: str
) -> bool:
"""Do the other servers list this outpoint as unspent, for the same amount, and
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
entries = await asyncio.wait_for(
client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS
)
return any(
e.get("tx_hash") == txid
and e.get("tx_pos") == vout
and e.get("value") == value
and (e.get("height") or 0) > 0
for e in entries
)
except Exception:
return None
finally:
await client.close()
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
async def _header_at(self, height: int) -> str | None:
"""The header of block `height` that a merkle proof is checked against: the tip
we hold (validated as it arrived), or for an older block the one a strict
majority of at least the quorum minimum of the other servers agree on, which must
also satisfy its own difficulty target. None when neither is available — in
particular for a height above our tip, which we have no way to check."""
if height <= 0 or height > self.tip_height:
return None
if height == self.tip_height and self.tip_header_hex:
return self.tip_header_hex
if height in self._window:
return self._window[height] # validated as it arrived (ELC-22)
async def _ask(client: ElectrumClient) -> str | None:
header = await client.get_block_header(height)
return header if isinstance(header, str) and header_meets_its_own_target(header) else None
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
if len(answers) < max(self._minimum_other_answers(), 1):
return None
header, votes = Counter(answers).most_common(1)[0]
return header if votes * 2 > len(answers) else None
async def confirmation_proven(self, txid: str, height: int) -> bool:
"""Is `txid` in the block at `height`? Accepted only when the quorum returns a
valid merkle proof — `blockchain.transaction.get_merkle` from the *other* servers
(never the active one, which is what a hostile peer controls) that recomputes to
the merkle root of that block's header, which we hold or the quorum agrees on. A
strict majority of at least the quorum minimum must prove it; otherwise the answer
is False and the caller simply asks again at its next pass (a delay, never a
loss)."""
header = await self._header_at(height)
if header is None:
logger.warning("cannot check the merkle proof of %s at height %s: no trusted header for it", txid, height)
return False
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
proof = await asyncio.wait_for(client.get_merkle(txid, height), timeout=_CORROBORATION_TIMEOUT_SECONDS)
return proof_matches_header(txid, height, proof, header)
except Exception:
return None
finally:
await client.close()
return await self._corroborate_majority(_ask, lambda proven: proven, f"merkle proof of {txid} at height {height}")
async def _consume_headers(self, queue: asyncio.Queue, client: ElectrumClient | None = None) -> None:
while True:
params = await queue.get()
for header in params:
self._apply_header(header)
await self._apply_header(header, client)
# A new block is exactly what the "drawing" phase is waiting on
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
# refetch instead of waiting for their next poll.
@@ -400,20 +952,63 @@ class ElectrumListener:
async def refresh_user(self, user_id: int, scripthash: str) -> None:
"""Three phases, so no DB session is held across a network call (B-18),
same shape as _trigger_payout: read what's needed, corroborate any
candidate external spends against other servers (B-29), then persist.
same shape as _trigger_payout: read what's needed, corroborate every
balance-moving candidate against other servers — new credits (B-59) as
well as candidate external spends (B-29) — then persist.
"""
assert self.client is not None
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
candidates = [
(row.id, row.txid, row.vout)
for row in await find_utxos_missing_from(session, user_id, entries)
]
corroborated_credits = [
entry
for entry in credit_candidates
if await self.corroborate_utxo_credit(
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
)
]
# B-83 (ELC-25, DEP-08): a coin written off as spent outside the platform comes back
# into the balance only on the same quorum as a credit.
corroborated_reinstatements = [
entry
for entry in reinstate_candidates
if await self.corroborate_utxo_reinstatement(
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
)
]
reinstated = 0
if corroborated_reinstatements:
async with self._session_factory() as session:
reinstated = await reinstate_reappeared_utxos(session, user_id, corroborated_reinstatements)
if len(corroborated_reinstatements) < len(reinstate_candidates):
logger.warning(
"%s reappeared UTXO(s) for user_id=%s not corroborated by the other servers — "
"left out of the balance, will retry on the next refresh",
len(reinstate_candidates) - len(corroborated_reinstatements),
user_id,
)
credited = 0
if corroborated_credits:
async with self._session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, corroborated_credits)
if len(corroborated_credits) < len(credit_candidates):
logger.warning(
"%s new UTXO(s) for user_id=%s not corroborated by the other servers — "
"not credited yet, will retry on the next refresh",
len(credit_candidates) - len(corroborated_credits),
user_id,
)
confirmed_ids = [
utxo_id
for utxo_id, txid, vout in candidates
+57
View File
@@ -0,0 +1,57 @@
"""Merkle proofs (B-83, TX-22, ELC-26).
`blockchain.transaction.get_merkle` answers with the branch that links a transaction to
its block's merkle root. Checking it against a header **we hold** is what turns "a server
says this tx is at height h" into something a hostile server cannot fake without mining.
"""
import hashlib
_HEADER_LENGTH_BYTES = 80
_MERKLE_ROOT_OFFSET = 36
_MERKLE_ROOT_LENGTH = 32
_MAX_BRANCH_LENGTH = 40 # 2^40 transactions in a block is already absurd
def _sha256d(data: bytes) -> bytes:
return hashlib.sha256(hashlib.sha256(data).digest()).digest()
def header_merkle_root(header_hex: str) -> bytes:
"""The merkle root committed to by a raw 80-byte header, in internal byte order."""
header = bytes.fromhex(header_hex)
if len(header) != _HEADER_LENGTH_BYTES:
raise ValueError(f"a block header is {_HEADER_LENGTH_BYTES} bytes, got {len(header)}")
return header[_MERKLE_ROOT_OFFSET : _MERKLE_ROOT_OFFSET + _MERKLE_ROOT_LENGTH]
def merkle_root_from_branch(txid: str, branch: list[str], position: int) -> bytes:
"""The merkle root (internal byte order) a transaction `txid` at index `position` of
its block reaches through `branch` — the sibling hashes, bottom-up, in the display
order Electrum uses for txids."""
if position < 0 or len(branch) > _MAX_BRANCH_LENGTH or position >> len(branch):
raise ValueError("merkle position does not fit the branch")
current = bytes.fromhex(txid)[::-1]
index = position
for sibling_hex in branch:
sibling = bytes.fromhex(sibling_hex)[::-1]
if len(sibling) != 32:
raise ValueError("merkle branch element is not 32 bytes")
current = _sha256d(sibling + current) if index & 1 else _sha256d(current + sibling)
index >>= 1
return current
def proof_matches_header(txid: str, height: int, proof: object, header_hex: str) -> bool:
"""Does `proof` — a `get_merkle` reply — put `txid` in the block at `height` whose
header is `header_hex`? Any malformed reply is simply "no"."""
try:
if not isinstance(proof, dict) or int(proof["block_height"]) != height:
return False
branch = proof["merkle"]
if not isinstance(branch, list):
return False
root = merkle_root_from_branch(txid, [str(item) for item in branch], int(proof["pos"]))
return root == header_merkle_root(header_hex)
except (KeyError, TypeError, ValueError):
return False
+27
View File
@@ -0,0 +1,27 @@
"""What a question put to every server came back with (B-78).
`active` is the answer of the connection the platform is running on, `others` those of
each other configured server in configuration order; `None` stands for "no usable
answer" (unreachable, timed out, an error that is not an answer)."""
from typing import NamedTuple
class ServerAnswers(NamedTuple):
active: object | None
others: tuple
others_needed: int # how many of `others` must have answered for the network to count
@property
def answered_others(self) -> list:
return [answer for answer in self.others if answer is not None]
@property
def complete(self) -> bool:
"""Enough of the network answered for its silence to mean something: the active
server and at least `others_needed` of the others."""
return self.active is not None and len(self.answered_others) >= self.others_needed
@property
def all(self) -> list:
return ([] if self.active is None else [self.active]) + self.answered_others
+10 -6
View File
@@ -22,7 +22,7 @@ from app.api.routes.users import router as users_router
from app.api.routes.withdrawals import router as withdrawals_router
from app.auth.routes import router as auth_router
from app.api.errors import ApiError
from app.config import settings, validate_runtime_secrets
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
from app.db.base import AsyncSessionLocal
from app.deposits.reconcile import DepositReconciler
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
@asynccontextmanager
@@ -50,19 +50,23 @@ async def lifespan(app: FastAPI):
settings.electrum_port,
settings.electrum_use_ssl,
settings.electrum_fallback_servers,
settings.electrum_cert_pin,
)
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints)
listener = ElectrumListener(
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
)
app.state.electrum_listener = listener
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
# Resolves in-flight transactions against the chain — the piece that lets the
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client)
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client, network=listener)
# Periodic safety net for deposit crediting/external-spend detection,
# independent of scripthash-change notifications — catches a subscription
# silently lost on an otherwise healthy connection (B-30).
+67 -11
View File
@@ -1,4 +1,5 @@
import hashlib
import secrets
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
@@ -9,6 +10,16 @@ _BITS_OFFSET = 72
_BITS_LENGTH = 4
# B-74 (ELC-11): the proof-of-work floor of PLM mainnet — `consensus.powLimit` in
# palladiumcore/src/chainparams.cpp, the same as Bitcoin's. A header claiming a target
# *easier* than this was not mined to the chain's rules, whatever its own hash says, so
# the claimed target alone (which is whoever fabricates the header's to choose) is not a
# proof of work. Read through the module at call time so a test can swap in a regtest
# limit to mine headers cheaply; production code never changes it.
MAINNET_POW_LIMIT = int("00000000" + "ff" * 28, 16)
POW_LIMIT = MAINNET_POW_LIMIT
class HeaderValidationError(Exception):
"""Raised by ElectrumListener._apply_header (B-28) when a header either doesn't
satisfy the difficulty target it claims for itself, or doesn't chain from the
@@ -48,7 +59,8 @@ def _target_from_bits(bits: int) -> int:
def header_meets_its_own_target(header_hex: str) -> bool:
"""Whether this header's hash satisfies the difficulty target *it claims for
itself* (the `bits` field). Rejects a header that was never actually mined —
itself* (the `bits` field), and that target is no easier than the chain's powLimit
(B-74, ELC-11 — without that, a fabricated header just claims `bits = 0x207fffff`). Rejects a header that was never actually mined —
e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28),
since satisfying a self-chosen target still requires real proof-of-work.
@@ -57,8 +69,8 @@ def header_meets_its_own_target(header_hex: str) -> bool:
a real but implausibly low self-chosen difficulty: a server could still declare
an easy target and grind it out with modest hardware. That residual risk is why
the draw additionally requires the winning block's header to be corroborated by
the *other* configured servers before using it as the seed (see
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
the configured servers at a fixed height before using it as the seed (see
ElectrumListener.agreed_block_hash and rounds/scheduler.py:_wait_for_draw_block)
rather than relying on this check alone.
"""
header_bytes = bytes.fromhex(header_hex)
@@ -66,7 +78,7 @@ def header_meets_its_own_target(header_hex: str) -> bool:
return False
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
target = _target_from_bits(bits)
if target <= 0:
if target <= 0 or target > POW_LIMIT: # B-74: not easier than the chain's powLimit
return False
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
# The hash as the integer comparable against `target`: this is the same digest
@@ -77,12 +89,56 @@ def header_meets_its_own_target(header_hex: str) -> bool:
return hash_int <= target
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
"""v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer,
index = seed mod participant_count, winner = participants[index]. Anyone can
recompute and verify it from public data. Deliberately simple/replaceable."""
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def new_server_seed() -> str:
"""B-73 (RND-34, DAT-64): a round's secret — 32 random bytes from the OS CSPRNG, as
64 lowercase hex characters."""
return secrets.token_bytes(32).hex()
def server_seed_commitment(server_seed_hex: str) -> str:
"""`server_seed_hash`: sha256 of the secret's 32 bytes, published from the opening of
the round so the operator cannot change the secret afterwards (RND-34)."""
return sha256_hex(bytes.fromhex(server_seed_hex))
def participants_hash(rows: list[tuple[str, int, str]]) -> str:
"""The fingerprint of the ordered draw list (RND-14): sha256 of one `txid:vout:address`
line per participant, in `draw_position` order, each line ending in `\n`, UTF-8. `txid`
is the bet transaction's, lowercase hex as explorers show it; `vout` is the output paying
the round's pool address, in decimal; `address` is the participant's own."""
text = "".join(f"{txid}:{vout}:{address}\n" for txid, vout, address in rows)
return sha256_hex(text.encode("utf-8"))
def draw_seed(server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> int:
"""RND-17: sha256 of the secret (32 bytes), the hash of the block at `draw_height` (32
bytes, in the order explorers show it) and `participants_hash` (32 bytes), read as an
unsigned big-endian integer. Nobody who lacks the secret can predict it, and the
operator, who knows the secret, cannot know the block when committing to the list."""
digest = hashlib.sha256(
bytes.fromhex(server_seed_hex) + bytes.fromhex(block_hash_hex) + bytes.fromhex(participants_hash_hex)
).digest()
return int.from_bytes(digest, "big")
def draw_index(seed: int, participant_count: int) -> int:
"""The winner's `draw_position`: seed modulo the number of participants. Every
participant holds exactly one position, so odds are equal whatever the amount."""
if participant_count <= 0:
raise ValueError("no participants to draw from")
return seed % participant_count
def draw_winner(participants: list[str], server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> str:
"""The v1 draw algorithm (docs/ciclo-di-vita-del-round.md RND-17), recomputable by anyone
from the public proof (GET /rounds/{id}/proof): `participants` are the entries in
`draw_position` order, the winner is the one at `draw_index(draw_seed(...), n)`. Deliberately
simple and replaceable."""
if not participants:
raise ValueError("no participants to draw from")
seed = int(block_hash_hex, 16)
index = seed % len(participants)
return participants[index]
seed = draw_seed(server_seed_hex, block_hash_hex, participants_hash_hex)
return participants[draw_index(seed, len(participants))]
+30
View File
@@ -0,0 +1,30 @@
"""Pausing the lottery from code (B-78, DAT-69, AUD-11).
A pause only stops the *next* round from opening (rounds/service.py:rounds_can_open); a
round in progress still closes, draws and pays. Besides the operator's own switch there
are pauses the system raises itself when something needs a human look — a transaction
given up for dead that confirmed after all (`resurrected_tx`, B-78) and, in later
findings, a deep reorg (`reorg`, B-85) and a payout whose winning bet left the chain
(`payout_blocked`, B-82).
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.rounds.config import get_round_config
OPERATOR_REASON = "operator"
AUTOMATIC_REASONS = ("reorg", "payout_blocked", "resurrected_tx")
async def pause_automatically(session: AsyncSession, reason: str) -> None:
"""Pause the lottery for an automatic `reason` in the caller's transaction and write
the `lottery_paused` row. An automatic reason is written even over a pause that is
already active (DAT-69): the cause that needs checking must not be hidden behind an
earlier one, and an operator pause is never what overrides it."""
if reason not in AUTOMATIC_REASONS:
raise ValueError(f"not an automatic pause reason: {reason!r}")
config = await get_round_config(session)
config.paused = True
config.paused_reason = reason
await write_audit_log(session, "lottery_paused", {"reason": reason})
+291 -97
View File
@@ -5,17 +5,28 @@ from datetime import datetime, timedelta, timezone
from embit import script
from embit.transaction import Transaction
from sqlalchemy import func, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.config import get_round_config
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.draw import (
draw_index,
draw_seed,
new_server_seed,
participants_hash,
server_seed_commitment,
)
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, winner_share
from app.wallet.hd import derive_pool_key
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key
from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -39,10 +50,10 @@ _DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
class RoundScheduler:
"""Background task implementing flowchart.mmd's DRAW subgraph: closes the
round on its timer (once any in-flight bets have confirmed), draws a winner
from the next confirmed block, and broadcasts the payout. The next round only
opens once this one is fully closed (rounds/service.get_active_round)."""
"""Background task implementing the round lifecycle (docs/ciclo-di-vita-del-round.md):
closes the round on its timer (once any in-flight bets have confirmed), draws a
winner from the next confirmed block, and broadcasts the payout. The next round
only opens once this one is fully closed (rounds/service.get_active_round)."""
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
self._session_factory = session_factory
@@ -63,12 +74,19 @@ class RoundScheduler:
return
async with self._session_factory() as session:
# B-88: every state transition below starts with BEGIN IMMEDIATE (DAT-74).
# A busy database raises out of the tick, which run() logs and retries at
# the next one — nothing has been written yet.
await begin_immediate(session)
round_ = await open_new_round_if_needed(session)
await session.commit()
if round_ is None:
return # still in the cooldown window after the last round closed
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
round_id, status = round_.id, round_.status
# B-61: this round's own snapshotted deadline, not one recomputed from
# whatever the config says now — an operator lowering the duration
# mid-round used to close the round on the spot.
deadline = round_deadline(round_)
if status == "paying_out":
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
@@ -83,11 +101,11 @@ class RoundScheduler:
return # "drawing" — progress happens inside the in-flight _close_and_draw call
if status == "open":
opened_at = opened_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
if datetime.now(timezone.utc) < deadline:
return
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
# No new bets from here on, regardless of how long the pending-bet
# wait below takes — flip to "closing" immediately so it's observable
@@ -117,50 +135,169 @@ class RoundScheduler:
await self._close_and_draw(round_id)
async def _close_and_draw(self, round_id: int) -> None:
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
participant) or commit to the draw — fix the participant list, its fingerprint and the
draw height, all in one transaction, and enter `drawing` (RND-13, RND-14, RND-33,
DAT-65) — then run the draw itself (`_complete_draw`)."""
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
# B-53: re-check for in-flight bets in the *same* session that snapshots
# the participants. _tick's check ran in a session of its own, so a bet
# committing its "building" row in between was counted by neither: the
# round drew and paid out without it, while its sats still landed in the
# pool. place_bet's compare-and-set on the round row is what makes that
# window unreachable; this is the cheap second lock on the same door, and
# it fails safe — the round stays "closing" and the next tick retries.
pending_count = await session.scalar(
select(func.count())
.select_from(RoundParticipant)
.where(
RoundParticipant.round_id == round_id,
RoundParticipant.status.in_(("building", "broadcast")),
)
)
if pending_count:
logger.info(
"round %s: %s bet(s) still in flight at close time, waiting", round_id, pending_count
)
return
# B-73 (RND-14, DAT-66): the draw order is the bet txid, ascending — a value the
# public can read off the chain, with no ties (two bets never share a txid). The
# server-only `broadcast_at` it replaces could not be checked by anyone.
participants = (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
.order_by(RoundParticipant.broadcast_at)
.order_by(RoundParticipant.bet_txid)
)
).all()
if not participants:
round_.status = "closed"
# DAT-64: nothing was drawn, so the secret has nothing left to protect.
if round_.server_seed_enc and not round_.server_seed:
round_.server_seed = decrypt_round_seed(round_.server_seed_enc)
await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id)
await session.commit()
broadcaster.publish()
logger.info("round %s closed with no participants", round_id)
return
pool_amount = sum(p.bet_amount_sats for p in participants)
addresses: list[str] = []
user_by_address: dict[str, int] = {}
for p in participants:
user = await session.get(User, p.user_id)
addresses.append(user.address)
user_by_address[user.address] = user.id
# B-73 (RND-19, RND-33): draw_height is "the height we knew at closing, plus one", so
# an unknown height (0) cannot be a base — the round stays "closing" and this is
# tried again at the next step, with one audit row per round.
tip_at_close = self._listener.tip_height
if tip_at_close <= 0:
already_written = await session.scalar(
select(func.count())
.select_from(AuditLog)
.where(AuditLog.event_type == "draw_baseline_tip_unknown", AuditLog.round_id == round_id)
)
if not already_written:
logger.warning("round %s: chain tip unknown at closing time, not entering drawing yet", round_id)
await write_audit_log(session, "draw_baseline_tip_unknown", {}, round_id=round_id)
await session.commit()
return
users = {
user.id: user
for user in (
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
).all()
}
if not round_.server_seed_enc:
# A round opened before B-73 has no commitment. Give it a secret now: it still
# cannot be known to anyone who could steer the block, because the list and the
# height are fixed below before that block exists — only the early publication
# of the commitment is missing for this one round.
seed = new_server_seed()
round_.server_seed_hash = server_seed_commitment(seed)
round_.server_seed_enc = encrypt_round_seed(seed)
for position, participant in enumerate(participants):
participant.draw_position = position
round_.participants_hash = participants_hash(
[(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
)
round_.draw_height = tip_at_close + 1
round_.pool_amount_sats = sum(p.bet_amount_sats for p in participants)
round_.status = "drawing"
drawing_started_at = datetime.now(timezone.utc)
round_.drawing_started_at = drawing_started_at
round_.drawing_started_at = datetime.now(timezone.utc)
await write_audit_log(
session,
"draw_committed",
{
"participants_hash": round_.participants_hash,
"draw_height": round_.draw_height,
"participants": len(participants),
"server_seed_hash": round_.server_seed_hash,
},
round_id=round_id,
)
await session.commit()
broadcaster.publish()
tip_at_close = self._listener.tip_height
block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at)
winner_address = draw_winner(addresses, block_hash)
await self._complete_draw(round_id)
async def _complete_draw(self, round_id: int) -> None:
"""The draw of a round in `drawing`, from nothing but what the database holds (RND-36,
DAT-65): wait for the block at `draw_height` to be confirmed and agreed on by the
quorum, compute the winner, store it and reveal the secret in one transaction, then
start the payout. Idempotent — a round no longer in `drawing`, or with a winner
already stored, is left alone."""
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
round_.draw_block_height = block_height
if round_ is None or round_.status != "drawing" or round_.winner_user_id is not None:
return
draw_height = round_.draw_height
draw_confirmations = round_.draw_confirmations
committed_hash = round_.participants_hash
server_seed_enc = round_.server_seed_enc
server_seed_hash = round_.server_seed_hash
waiting_since = round_.drawing_started_at.replace(tzinfo=timezone.utc)
block_hash = await self._wait_for_draw_block(round_id, draw_height, draw_confirmations, waiting_since)
server_seed = decrypt_round_seed(server_seed_enc)
if server_seed_commitment(server_seed) != server_seed_hash:
raise RuntimeError(f"round {round_id}: the stored secret does not match its published commitment")
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
if round_.status != "drawing" or round_.winner_user_id is not None:
return # drawn meanwhile (a second pass over the same round)
participants = (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
.order_by(RoundParticipant.draw_position)
)
).all()
users = {
user.id: user
for user in (
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
).all()
}
ordered_rows = [(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
if participants_hash(ordered_rows) != committed_hash:
raise RuntimeError(f"round {round_id}: the draw list no longer matches its published fingerprint")
seed = draw_seed(server_seed, block_hash, committed_hash)
winner = participants[draw_index(seed, len(participants))]
winner_address = users[winner.user_id].address
pool_amount = sum(p.bet_amount_sats for p in participants if p.status == "confirmed")
round_.draw_block_height = draw_height
round_.draw_block_hash = block_hash
round_.seed_int = str(int(block_hash, 16))
round_.winner_user_id = user_by_address[winner_address]
round_.seed_int = str(seed)
round_.winner_user_id = winner.user_id
round_.pool_amount_sats = pool_amount
# The reveal (RND-18, DAT-64): the plaintext secret becomes visible in the same
# transaction that stores the winner, never before.
round_.server_seed = server_seed
round_.status = "paying_out"
await write_audit_log(
session,
@@ -168,11 +305,15 @@ class RoundScheduler:
{
"winner_address": winner_address,
"pool_amount_sats": pool_amount,
"block_height": block_height,
"block_height": draw_height,
"block_hash": block_hash,
"participants": len(addresses),
"participants": len(participants),
"draw_height": draw_height,
"participants_hash": committed_hash,
"server_seed": server_seed,
"seed": f"{seed:064x}",
},
user_id=user_by_address[winner_address],
user_id=winner.user_id,
round_id=round_id,
)
await session.commit()
@@ -181,75 +322,94 @@ class RoundScheduler:
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
await self._trigger_payout(round_id)
async def _wait_for_next_block(
self, round_id: int, tip_at_close: int, waiting_since: datetime
) -> tuple[int, str]:
"""Waits for a block after tip_at_close and, before handing it back as the
draw's entropy source, requires it to be corroborated by the other
configured Electrum servers (B-28) — our own active connection is exactly
the thing a hostile server or a MITM would control, so its header alone is
not enough to seed a payout. A candidate that fails corroboration is never
used: this keeps waiting for a further block and tries corroborating that
one instead, logging why every time so a stuck draw is visible in
/admin's audit log rather than a silent, unexplained wait.
async def _wait_for_draw_block(
self, round_id: int, draw_height: int, confirmations: int, waiting_since: datetime
) -> str:
"""The hash of the block at `draw_height`, once it is safe to seed the draw with it
(RND-15, RND-16, RND-20; B-73): the chain must have `confirmations` blocks from that
height (a tip of at least `draw_height + confirmations - 1`), and then the quorum of
servers must agree on the header *at that exact height* — never on the tip, never on
a later block. If they do not agree the same height is asked again at the next step
and `draw_header_corroboration_failed` is written once per distinct hash the active
server reported, so a disagreement that lasts does not write a row every 5 seconds.
Whoever produces or relays blocks therefore cannot choose which block seeds the draw:
withholding or delaying one only delays the draw.
This wait has no timeout — it can't, since the draw's entropy genuinely
depends on a future block. B-36: what it lacked was *visibility*, so a
connection that stopped advancing the tip left the round silently frozen
in "drawing" with nothing in the logs or /admin to explain why. Progress
is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a
draw_stalled audit entry is written (and re-written every threshold
interval for as long as the stall continues) so the wait shows up next
to the draw_header_corroboration_failed entries above.
The wait has no timeout — the entropy genuinely depends on a future block — but it is
observable (B-36): progress is logged every _DRAW_PROGRESS_LOG_INTERVAL_SECONDS, and a
`draw_stalled` row is written when for _DRAW_STALL_THRESHOLD_SECONDS the chain has not
advanced while the round waits for confirmations, or the quorum has been failing to
agree — and repeated every threshold for as long as that lasts. The clock restarts
whenever the tip advances during the confirmation wait, so a chain that is merely
producing its three blocks at the normal pace never counts as stalled.
"""
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
required_tip = draw_height + confirmations - 1
now = datetime.now(timezone.utc)
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
stall_since = now
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
last_tip = self._listener.tip_height
agreeing = False # True once the confirmations are there and only the quorum is awaited
reported_hashes: set[str | None] = set()
while True:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
now = datetime.now(timezone.utc)
if now >= next_progress_log_at:
logger.info(
"round %s: still waiting for a block past height %s (%.0fs since drawing started)",
now = datetime.now(timezone.utc)
tip = self._listener.tip_height
if tip >= required_tip:
if not agreeing:
agreeing = True
stall_since = now
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
block_hash, reported = await self._listener.agreed_block_hash(draw_height)
if block_hash is not None:
return block_hash
if reported not in reported_hashes:
reported_hashes.add(reported)
logger.error(
"round %s: block %s header (active server: %s) is not agreed by the other Electrum "
"servers; asking again for the same height",
round_id,
tip_at_close,
(now - waiting_since).total_seconds(),
draw_height,
reported,
)
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
if now >= next_stall_audit_at:
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_stalled",
{
"tip_at_close": tip_at_close,
"current_tip_height": self._listener.tip_height,
"elapsed_seconds": int((now - waiting_since).total_seconds()),
},
"draw_header_corroboration_failed",
{"height": draw_height, "reported_hash": reported},
round_id=round_id,
)
await session.commit()
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
height = self._listener.tip_height
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
if await self._listener.corroborate_header(height, block_hash):
return height, block_hash
logger.error(
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
"waiting for a further block",
round_id,
height,
block_hash,
)
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_header_corroboration_failed",
{"height": height, "reported_hash": block_hash},
round_id=round_id,
elif tip != last_tip:
last_tip = tip
stall_since = now
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
if now >= next_progress_log_at:
logger.info(
"round %s: still waiting for block %s (%s confirmation(s) needed, chain at %s, %.0fs since drawing started)",
round_id,
draw_height,
confirmations,
tip,
(now - waiting_since).total_seconds(),
)
await session.commit()
tip_at_close = height
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
if now >= next_stall_audit_at:
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_stalled",
{
"draw_height": draw_height,
"current_tip_height": tip,
"elapsed_seconds": int((now - stall_since).total_seconds()),
},
round_id=round_id,
)
await session.commit()
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
async def _retry_payout_if_due(self, round_id: int) -> None:
"""B-26: whether a "paying_out" round is due for another payout attempt.
@@ -298,6 +458,12 @@ class RoundScheduler:
# --- Phase 1: read (session closed before any network I/O) ---------------
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
# Nothing built or sent yet: try again at the next tick.
logger.warning("round %s payout deferred: database busy", round_id)
return
round_ = await session.get(Round, round_id)
already_in_flight = await session.scalar(
select(PendingTransaction).where(
@@ -322,6 +488,7 @@ class RoundScheduler:
fee_address = config.fee_address
fee_rate = config.fee_rate_sat_vb
pool_amount_sats = round_.pool_amount_sats
round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below)
winner_user_id = round_.winner_user_id
winner = await session.get(User, winner_user_id)
winner_address = winner.address if winner is not None else None
@@ -343,7 +510,10 @@ class RoundScheduler:
# --- Phase 2: build (network read only, no DB write yet) -----------------
try:
pool_key = derive_pool_key()
# B-80 (RND-22): the round's own key and address — only the coins at this
# round's pool address are ever read, so no other round's money (and no
# stray coin sent to another address) can end up among the inputs.
pool_key = derive_round_pool_key(round_id, round_pool_address)
pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET)
entries = await client.listunspent(address_to_scripthash(pool_address))
@@ -365,8 +535,12 @@ class RoundScheduler:
fee_rate_sat_vb=fee_rate,
)
except InsufficientFundsError as exc:
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
# over more UTXOs than one transaction may spend, so /admin has to say which.
# Includes the "too_many_inputs" case: the pool holds enough, but spread over
# more UTXOs than one transaction may spend, so /admin has to say which. Since
# B-52 that means MAX_PAYOUT_TX_INPUTS, and participants are capped below it at
# bet time (bets/service.py), so reaching it now takes pool change accumulated
# over many rounds rather than one busy round — an operator consolidation job,
# not a dead end for the bets of the round in progress.
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
logger.exception("round %s payout failed: %s", round_id, reason)
await self._log_payout_failure(round_id, winner_user_id, reason)
@@ -383,6 +557,13 @@ class RoundScheduler:
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
# Not written, not broadcast: the payout is simply rebuilt on the retry.
logger.warning("round %s payout deferred before broadcast: database busy", round_id)
await self._log_payout_failure(round_id, winner_user_id, "database busy")
return
round_ = await session.get(Round, round_id)
round_.winner_amount_sats = built.winner_sats
round_.fee_amount_sats = built.commission_sats
@@ -399,21 +580,34 @@ class RoundScheduler:
status="building",
)
session.add(pending)
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version
await session.commit()
pending_id = pending.id
# --- Phase 4: broadcast, then promote the pending row --------------------
try:
await client.broadcast(built.raw_hex)
except Exception:
# The row stays "building": the reconciler will ask the chain about it
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
# of the round being stuck with a payout_txid that never went anywhere.
logger.exception("round %s payout broadcast failed", round_id)
# B-76 (TX-05): the same three outcomes as a bet's broadcast. Accepted — the
# server took it, or refused with "already known" / a server of the network
# holds it — promotes the row; a refusal and an uncertain outcome both leave it
# "building" with a payout_failed entry, since which of the two it really was
# is the reconciler's call, made against the chain (TX-16, TX-17).
verdict = await broadcast_with_verdict(client, self._listener, built.raw_hex, built.txid)
if verdict.outcome is not BroadcastOutcome.ACCEPTED:
logger.error("round %s payout broadcast not accepted (%s): %s", round_id, verdict.outcome.value, verdict.reason)
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
return
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
# The tx is on the network; the row stays "building" and the
# reconciler finds it on the chain and promotes it (TX-28).
logger.error(
"round %s payout %s broadcast but the database is busy: left 'building'",
round_id,
built.txid,
)
return
pending = await session.get(PendingTransaction, pending_id)
pending.status = "pending"
await write_audit_log(
+71 -10
View File
@@ -5,9 +5,12 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Round
from app.db.models import Round, RoundConfig
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.rounds.draw import new_server_seed, server_seed_commitment
from app.wallet.hd import derive_pool_address
from app.wallet.keystore import encrypt_round_seed
logger = logging.getLogger(__name__)
@@ -23,6 +26,16 @@ _OPEN_ROUND_ATTEMPTS = 3
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
def _new_round_secret() -> dict:
"""B-73 (RND-34): a fresh secret for a round about to open, as the columns that hold it —
its published commitment and its encrypted form, never the plaintext."""
server_seed = new_server_seed()
return {
"server_seed_hash": server_seed_commitment(server_seed),
"server_seed_enc": encrypt_round_seed(server_seed),
}
def winner_share(pool_amount_sats: int) -> int:
return pool_amount_sats * 70 // 100
@@ -48,7 +61,15 @@ async def get_active_round(session: AsyncSession) -> Round | None:
return active[0] if active else None
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
def round_deadline(round_: Round) -> datetime:
"""When this round stops accepting bets. B-61: from the round's own snapshotted
duration, not from the live config — an operator editing round_duration_seconds
mid-round must not move a deadline clients are already counting down to, nor
close an in-progress round on the spot."""
return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds)
def round_accepts_bets(round_: Round) -> bool:
"""The authoritative "yellow light" check: once a round's timer has expired,
no new bet may be accepted, even though its DB status is still "open" (the
scheduler only flips it to "closing" on its next tick, up to
@@ -57,17 +78,32 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
before actually closing."""
if round_.status != "open":
return False
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
return datetime.now(timezone.utc) < round_deadline(round_)
def rounds_can_open(config: RoundConfig) -> bool:
"""Whether the instance is configured well enough to run a round at all (B-66).
Only fee_address today, and only because a round without one is unpayable: the
payout pays the 30% commission to it, so build_payout_transaction cannot even be
built. It has no column default for exactly this reason (rounds/config.py) — an
operator must set their own, and until they do there is nothing to guess.
Anything else that would make a round unpayable belongs here too, next to it,
rather than being discovered at payout time. Deliberately not about *pausing*,
which is a decision an operator took (RoundConfig.paused) rather than a
prerequisite they haven't met yet."""
return bool(config.fee_address.strip())
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
"""Returns the active round if one exists (whatever its status). Otherwise
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
hasn't elapsed yet, or the lottery is paused for maintenance — in either case
returns None. Callers that need to attach a bet must additionally check the
returned round's status == "open" — a round in closing/drawing/paying_out
isn't accepting new bets, but a new round can't open until it's done.
hasn't elapsed yet, the lottery is paused for maintenance, or the instance isn't
configured well enough to pay a winner — in any of those cases returns None.
Callers that need to attach a bet must additionally check the returned round's
status == "open" — a round in closing/drawing/paying_out isn't accepting new bets,
but a new round can't open until it's done.
Pausing never touches a round already in progress: it only suppresses opening
the *next* one, so the current round still closes, draws, and pays out the
@@ -79,15 +115,37 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
config = await get_round_config(session)
if config.paused:
return None
if not rounds_can_open(config):
# B-66: a fresh instance starts with no fee_address, and a round opened
# without one takes bets, confirms them, and only then discovers that the
# payout cannot be built — leaving the round wedged in "paying_out",
# retrying every 60s, with money already in the pool. Every round would
# need its own manual recovery. Refusing to open costs nothing by
# comparison: no money has moved yet, and it is the operator's own missing
# setup, surfaced through GET /rounds/current's lottery_configured and the
# admin panel rather than discovered a round too late.
return None
last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc()))
if last_closed is not None and last_closed.closed_at is not None:
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=config.round_cooldown_seconds):
# B-61: the cooldown the closing round announced is the one honoured, so
# editing the config never retroactively shortens or extends a gap already
# under way. The new value applies from the next round on.
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds):
return None
for attempt in range(_OPEN_ROUND_ATTEMPTS):
round_ = Round(status="open")
# B-61: the timing this round will run by, fixed at open time.
round_ = Round(
status="open",
duration_seconds=config.round_duration_seconds,
cooldown_seconds=config.round_cooldown_seconds,
draw_confirmations=config.draw_confirmations, # B-73 (DAT-61)
# B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the
# plaintext column stays NULL until the reveal.
**_new_round_secret(),
)
session.add(round_)
try:
await session.flush()
@@ -111,6 +169,9 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
# only tells subscribers "go refetch", and by the time an SSE client's
# refetch request actually lands, this in-process commit (microseconds
# away) has essentially always already happened.
# B-80 (RND-35, DAT-60): the round's own pool address, now that its id exists —
# still inside the caller's transaction, so no committed round ever lacks one.
round_.pool_address = derive_pool_address(round_.id)
broadcaster.publish()
return round_
+9
View File
@@ -62,6 +62,13 @@
</div>
<div class="card">
<!-- B-66: no fee_address means no round can open at all (the payout pays the
30% commission to it, so it cannot even be built). Shown here because
this is the one screen that can fix it. -->
<div class="warning-banner hidden" id="admin-fee-address-warning">
⚠️ Nessun fee address configurato: finché resta vuoto <strong>non si aprirà nessun round</strong>
(il payout non sarebbe costruibile). Impostalo qui sotto e salva.
</div>
<div class="grid-2">
<div>
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
@@ -76,6 +83,8 @@
<input id="admin-round-cooldown" inputmode="numeric" placeholder="es. 30">
<label for="admin-draw-animation">Durata animazione estrazione (secondi)</label>
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
+7
View File
@@ -141,10 +141,16 @@ async function adminLoadConfig() {
try {
const data = await callAdmin('GET', '/admin/config');
document.getElementById('admin-fee-address').value = data.fee_address;
// B-66: an empty fee address blocks every future round, so say so here rather
// than leaving an empty field to be noticed.
document
.getElementById('admin-fee-address-warning')
.classList.toggle('hidden', !!(data.fee_address || '').trim());
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
renderMaintenanceState(data.paused);
@@ -204,6 +210,7 @@ async function adminSave() {
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
};
+60 -11
View File
@@ -235,7 +235,18 @@ function renderChainStatusBar() {
label.textContent = t(CHAIN_STATUS_KEYS[labelKey]);
block.textContent = t('chain.block', { n: data.chain_tip_height != null ? '#' + data.chain_tip_height : '—' });
document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused);
// Two separate reasons no round will open, and telling them apart matters to the
// reader: a pause ends when the operator resumes, while an unconfigured instance
// (B-66) won't produce a round at all until it's set up — "come back later" would
// be a lie. `=== false` so an older server that doesn't send the field at all
// can't flash the banner. A pause takes precedence: it's the deliberate action.
const notConfigured = data.lottery_configured === false;
const noRoundsComing = !!data.lottery_paused || notConfigured;
document.getElementById('maintenance-banner').classList.toggle('hidden', !noRoundsComing);
if (noRoundsComing) {
document.getElementById('maintenance-banner-text').textContent =
data.lottery_paused ? t('maintenance.banner') : t('maintenance.notConfigured');
}
}
// After a couple of consecutive failed polls (network blip, server restart,
@@ -368,6 +379,49 @@ async function checkLastRoundResult() {
let lastJackpotValue = null;
// The round's own confirmed/in-flight split (B-65). The big numbers are the
// confirmed ones — the players the draw will pick from and the pool the payout
// will actually spend — because a jackpot advertised larger than the one paid out
// is the kind of gap nobody forgives. What has been bet but hasn't confirmed yet
// is shown next to them instead of being folded in, so the player who just bet
// still sees their own bet immediately (the same reasoning as the amber
// pending balance, see setBalanceDisplay).
//
// Kept out of the [data-i18n] mechanism on purpose: these come from server data,
// so onLanguageChange() re-renders them through t() like every other dynamic bit.
let lastRoundStats = null;
function renderRoundStats(data) {
lastRoundStats = data;
document.getElementById('round-players').textContent = data.participant_count;
const jackpotEl = document.getElementById('round-jackpot');
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
jackpotEl.textContent = formatPlm(data.jackpot_sats);
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
jackpotEl.classList.remove('jackpot-bump');
void jackpotEl.offsetWidth; // restart the animation
jackpotEl.classList.add('jackpot-bump');
}
lastJackpotValue = jackpotValue;
// pending_* are inclusive of the confirmed figures (like pending_balance_sats),
// so what's shown alongside is the difference.
const pendingPlayers = (data.pending_participant_count || 0) - data.participant_count;
const pendingJackpotSats = (data.pending_jackpot_sats || 0) - data.jackpot_sats;
const show = !!data.has_pending_bets && pendingPlayers > 0;
const playersPendingEl = document.getElementById('round-players-pending');
playersPendingEl.textContent = show ? t('round.playersPending', { n: pendingPlayers }) : '';
playersPendingEl.classList.toggle('hidden', !show);
const jackpotPendingEl = document.getElementById('round-jackpot-pending');
jackpotPendingEl.textContent = show
? t('round.jackpotPending', { amount: formatPlm(pendingJackpotSats) })
: '';
jackpotPendingEl.classList.toggle('hidden', !show);
}
let timerHitZero = false;
function updateRoundTimer() {
@@ -451,16 +505,7 @@ async function refreshRound() {
: t('round.none');
betAmountSats = data.bet_amount_sats;
renderBetButton();
document.getElementById('round-players').textContent = data.participant_count;
const jackpotEl = document.getElementById('round-jackpot');
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
jackpotEl.textContent = formatPlm(data.jackpot_sats);
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
jackpotEl.classList.remove('jackpot-bump');
void jackpotEl.offsetWidth; // restart the animation
jackpotEl.classList.add('jackpot-bump');
}
lastJackpotValue = jackpotValue;
renderRoundStats(data);
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer();
@@ -858,6 +903,10 @@ function connectRoundEvents() {
function onLanguageChange() {
renderBetButton();
renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll
// Same reason, for the round's "+N in attesa" suffixes (B-65): repaint from what
// was last received rather than leaving them in the old language until the
// refreshRound() below happens to come back.
if (lastRoundStats) renderRoundStats(lastRoundStats);
if (token) {
refreshRound();
refreshMe();
+56
View File
@@ -41,6 +41,7 @@ const TRANSLATIONS = {
'chain.block': 'Block {n}',
'chain.connectionLost': 'Connection to server lost — retrying…',
'maintenance.banner': 'Scheduled maintenance: the current round completes normally (winner included), but the next round will not open until maintenance ends.',
'maintenance.notConfigured': 'This lottery is not ready to play yet: the operator still has to finish setting it up, and no round will open until then.',
'hero.lead': 'Deposit PLM, join the round with a fixed entry fee, and if your number is drawn you win the jackpot.',
'hero.step1.title': '1. Deposit',
@@ -63,6 +64,8 @@ const TRANSLATIONS = {
'round.players': 'Players',
'round.jackpot': 'Jackpot',
'round.playersPending': '+{n} pending',
'round.jackpotPending': '+{amount} pending',
'round.status.open': 'open',
'round.status.closing': 'closing',
'round.status.drawing': 'drawing in progress',
@@ -139,11 +142,14 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Not connected to the network, please try again shortly.',
'error.no_round_open': 'No round is open right now, please try again shortly.',
'error.round_closing': 'The current round is closing, please try again shortly.',
'error.round_full': 'This round has reached its maximum of {max_participants} players — wait for the next one, it opens shortly.',
'error.already_betting': 'You already have an active bet in the current round.',
'error.insufficient_balance': 'Insufficient balance.',
'error.balance_leaves_no_change': 'Your balance is too close to the bet amount: {required_extra_plm} PLM more are needed so the transaction keeps a change output and can be fee-bumped if the network is slow.',
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
'error.round_not_found': 'Round not found.',
'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.',
'error.current_password_incorrect': 'The current password is incorrect.',
'error.password_too_short': 'The new password must be at least {minimum} characters.',
@@ -154,9 +160,11 @@ const TRANSLATIONS = {
'error.invalid_request': 'Invalid request, please check the entered data.',
'error.invalid_amount': 'Enter an amount greater than zero.',
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.',
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
'error.withdrawal_to_platform_address': 'That is an address of this platform — withdraw to an external wallet.',
'error.internal_error': 'Unexpected server error. Please try again shortly.',
'error.guide_unavailable': 'The guide is not available right now.',
'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.',
@@ -204,6 +212,7 @@ const TRANSLATIONS = {
'chain.block': 'Blocco {n}',
'chain.connectionLost': 'Connessione al server persa — riprovo…',
'maintenance.banner': 'Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.',
'maintenance.notConfigured': 'Questa lotteria non è ancora pronta: l\'operatore deve completare la configurazione, e fino a quel momento non si aprirà nessun round.',
'hero.lead': 'Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.',
'hero.step1.title': '1. Deposita',
@@ -226,6 +235,8 @@ const TRANSLATIONS = {
'round.players': 'Giocatori',
'round.jackpot': 'Jackpot',
'round.playersPending': '+{n} in attesa',
'round.jackpotPending': '+{amount} in attesa',
'round.status.open': 'aperto',
'round.status.closing': 'in chiusura',
'round.status.drawing': 'estrazione in corso',
@@ -299,11 +310,14 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.',
'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.',
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
'error.round_full': 'Questo round ha raggiunto il massimo di {max_participants} giocatori — aspetta il prossimo, si apre tra poco.',
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
'error.insufficient_balance': 'Saldo insufficiente.',
'error.balance_leaves_no_change': 'Il tuo saldo è troppo vicino all\'importo della giocata: servono {required_extra_plm} PLM in più perché la transazione mantenga un resto e possa essere rilanciata con fee più alta se la rete è lenta.',
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
'error.round_not_found': 'Round non trovato.',
'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.",
'error.current_password_incorrect': 'La password attuale non è corretta.',
'error.password_too_short': 'La nuova password deve avere almeno {minimum} caratteri.',
@@ -314,9 +328,11 @@ const TRANSLATIONS = {
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.',
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
'error.withdrawal_to_platform_address': 'Questo è un indirizzo della piattaforma — preleva verso un wallet esterno.',
'error.internal_error': 'Errore inatteso del server. Riprova tra poco.',
'error.guide_unavailable': 'La guida non è disponibile in questo momento.',
'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.',
@@ -364,6 +380,7 @@ const TRANSLATIONS = {
'chain.block': 'Bloque {n}',
'chain.connectionLost': 'Conexión con el servidor perdida — reintentando…',
'maintenance.banner': 'Mantenimiento programado: la ronda actual se completa con normalidad (ganador incluido), pero la siguiente ronda no se abrirá hasta que finalice el mantenimiento.',
'maintenance.notConfigured': 'Esta lotería todavía no está lista: el operador tiene que terminar de configurarla y, hasta entonces, no se abrirá ninguna ronda.',
'hero.lead': 'Deposita PLM, únete a la ronda con una cuota fija de entrada, y si sale tu número ganas el bote.',
'hero.step1.title': '1. Deposita',
@@ -386,6 +403,8 @@ const TRANSLATIONS = {
'round.players': 'Jugadores',
'round.jackpot': 'Bote',
'round.playersPending': '+{n} pendientes',
'round.jackpotPending': '+{amount} pendientes',
'round.status.open': 'abierta',
'round.status.closing': 'cerrando',
'round.status.drawing': 'sorteo en curso',
@@ -459,11 +478,14 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.',
'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.',
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
'error.round_full': 'Esta ronda ha alcanzado su máximo de {max_participants} jugadores: espera la siguiente, se abre en breve.',
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
'error.insufficient_balance': 'Saldo insuficiente.',
'error.balance_leaves_no_change': 'Tu saldo está demasiado cerca del importe de la apuesta: hacen falta {required_extra_plm} PLM más para que la transacción conserve un cambio y pueda relanzarse con más comisión si la red va lenta.',
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
'error.round_not_found': 'Ronda no encontrada.',
'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.',
'error.current_password_incorrect': 'La contraseña actual no es correcta.',
'error.password_too_short': 'La nueva contraseña debe tener al menos {minimum} caracteres.',
@@ -474,9 +496,11 @@ const TRANSLATIONS = {
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
'error.invalid_amount': 'Introduce un importe mayor que cero.',
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.',
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
'error.withdrawal_to_platform_address': 'Esa es una dirección de la propia plataforma — retira a una cartera externa.',
'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.',
'error.guide_unavailable': 'La guía no está disponible en este momento.',
'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.',
@@ -524,6 +548,7 @@ const TRANSLATIONS = {
'chain.block': 'Bloc {n}',
'chain.connectionLost': 'Connexion au serveur perdue — nouvelle tentative…',
'maintenance.banner': "Maintenance programmée : le round en cours se termine normalement (gagnant inclus), mais le round suivant ne s'ouvrira qu'une fois la maintenance terminée.",
'maintenance.notConfigured': "Cette loterie n'est pas encore prête : l'opérateur doit terminer la configuration, et aucun round ne s'ouvrira avant.",
'hero.lead': 'Déposez des PLM, rejoignez le round avec une mise fixe, et si votre numéro est tiré vous remportez le jackpot.',
'hero.step1.title': '1. Déposez',
@@ -546,6 +571,8 @@ const TRANSLATIONS = {
'round.players': 'Joueurs',
'round.jackpot': 'Jackpot',
'round.playersPending': '+{n} en attente',
'round.jackpotPending': '+{amount} en attente',
'round.status.open': 'ouvert',
'round.status.closing': 'en fermeture',
'round.status.drawing': 'tirage en cours',
@@ -619,11 +646,14 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.',
'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.",
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
'error.round_full': 'Ce tour a atteint son maximum de {max_participants} joueurs — attendez le prochain, il ouvre dans un instant.',
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
'error.insufficient_balance': 'Solde insuffisant.',
'error.balance_leaves_no_change': 'Votre solde est trop proche du montant de la mise : il faut {required_extra_plm} PLM de plus pour que la transaction garde une monnaie de rendu et puisse être relancée avec des frais plus élevés si le réseau est lent.',
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
'error.round_not_found': 'Manche introuvable.',
'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.',
'error.current_password_incorrect': "Le mot de passe actuel n'est pas correct.",
'error.password_too_short': 'Le nouveau mot de passe doit comporter au moins {minimum} caractères.',
@@ -634,9 +664,11 @@ const TRANSLATIONS = {
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.',
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
'error.withdrawal_to_platform_address': "C'est une adresse de la plateforme elle-même — retirez vers un portefeuille externe.",
'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.",
'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.',
@@ -684,6 +716,7 @@ const TRANSLATIONS = {
'chain.block': 'Block {n}',
'chain.connectionLost': 'Verbindung zum Server verloren — erneuter Versuch…',
'maintenance.banner': 'Geplante Wartung: Die laufende Runde wird regulär abgeschlossen (Gewinner inklusive), aber die nächste Runde öffnet erst, wenn die Wartung beendet ist.',
'maintenance.notConfigured': 'Diese Lotterie ist noch nicht spielbereit: der Betreiber muss die Einrichtung abschließen, bis dahin öffnet keine Runde.',
'hero.lead': 'Zahle PLM ein, nimm mit einem festen Einsatz an der Runde teil, und wenn deine Zahl gezogen wird, gewinnst du den Jackpot.',
'hero.step1.title': '1. Einzahlen',
@@ -706,6 +739,8 @@ const TRANSLATIONS = {
'round.players': 'Spieler',
'round.jackpot': 'Jackpot',
'round.playersPending': '+{n} ausstehend',
'round.jackpotPending': '+{amount} ausstehend',
'round.status.open': 'offen',
'round.status.closing': 'wird geschlossen',
'round.status.drawing': 'Ziehung läuft',
@@ -779,11 +814,14 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.',
'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.',
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
'error.round_full': 'Diese Runde hat ihr Maximum von {max_participants} Spielern erreicht — warten Sie auf die nächste, sie beginnt in Kürze.',
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
'error.insufficient_balance': 'Nicht genügend Guthaben.',
'error.balance_leaves_no_change': 'Ihr Guthaben liegt zu nah am Einsatzbetrag: Es werden {required_extra_plm} PLM mehr benötigt, damit die Transaktion einen Wechselgeld-Ausgang behält und bei langsamem Netz mit höherer Gebühr neu gesendet werden kann.',
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
'error.round_not_found': 'Runde nicht gefunden.',
'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.',
'error.current_password_incorrect': 'Das aktuelle Passwort ist nicht korrekt.',
'error.password_too_short': 'Das neue Passwort muss mindestens {minimum} Zeichen lang sein.',
@@ -794,9 +832,11 @@ const TRANSLATIONS = {
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.',
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
'error.withdrawal_to_platform_address': 'Das ist eine Adresse der Plattform selbst — zahle auf eine externe Wallet aus.',
'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.',
@@ -844,6 +884,7 @@ const TRANSLATIONS = {
'chain.block': 'Блок {n}',
'chain.connectionLost': 'Соединение с сервером потеряно — повторная попытка…',
'maintenance.banner': 'Запланировано техобслуживание: текущий раунд завершится в обычном порядке (включая победителя), но следующий раунд не откроется до окончания техобслуживания.',
'maintenance.notConfigured': 'Эта лотерея пока не готова: оператору нужно завершить настройку, до этого ни один раунд не откроется.',
'hero.lead': 'Внесите PLM, вступите в раунд с фиксированной ставкой, и если выпадет ваш номер — вы выиграете джекпот.',
'hero.step1.title': '1. Внесите депозит',
@@ -866,6 +907,8 @@ const TRANSLATIONS = {
'round.players': 'Игроки',
'round.jackpot': 'Джекпот',
'round.playersPending': '+{n} в ожидании',
'round.jackpotPending': '+{amount} в ожидании',
'round.status.open': 'открыт',
'round.status.closing': 'закрывается',
'round.status.drawing': 'идёт розыгрыш',
@@ -939,11 +982,14 @@ const TRANSLATIONS = {
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.',
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
'error.insufficient_balance': 'Недостаточно средств.',
'error.balance_leaves_no_change': 'Ваш баланс слишком близок к сумме ставки: нужно ещё {required_extra_plm} PLM, чтобы в транзакции остался выход сдачи и её можно было переотправить с более высокой комиссией, если сеть работает медленно.',
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
'error.round_not_found': 'Раунд не найден.',
'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.',
'error.current_password_incorrect': 'Текущий пароль указан неверно.',
'error.password_too_short': 'Новый пароль должен содержать не менее {minimum} символов.',
@@ -954,9 +1000,11 @@ const TRANSLATIONS = {
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
'error.invalid_amount': 'Введите сумму больше нуля.',
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.',
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
'error.withdrawal_to_platform_address': 'Это адрес самой платформы — выводите на внешний кошелёк.',
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
'error.guide_unavailable': 'Руководство сейчас недоступно.',
'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.',
@@ -1004,6 +1052,7 @@ const TRANSLATIONS = {
'chain.block': '区块 {n}',
'chain.connectionLost': '与服务器的连接已断开——正在重试…',
'maintenance.banner': '计划维护:当前回合将照常完成(包括中奖者),但下一回合要等维护结束后才会开启。',
'maintenance.notConfigured': '本彩票尚未就绪:运营方还需完成配置,在此之前不会开启任何回合。',
'hero.lead': '存入 PLM,以固定金额参与本回合,若抽中你的号码即可赢得奖池。',
'hero.step1.title': '1. 存款',
@@ -1026,6 +1075,8 @@ const TRANSLATIONS = {
'round.players': '参与人数',
'round.jackpot': '奖池',
'round.playersPending': '+{n} 待确认',
'round.jackpotPending': '+{amount} 待确认',
'round.status.open': '进行中',
'round.status.closing': '即将结束',
'round.status.drawing': '正在开奖',
@@ -1099,11 +1150,14 @@ const TRANSLATIONS = {
'error.network_unavailable': '未连接到网络,请稍后重试。',
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
'error.round_closing': '当前回合正在结束,请稍后重试。',
'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。',
'error.already_betting': '你在当前回合已有一笔有效下注。',
'error.insufficient_balance': '余额不足。',
'error.balance_leaves_no_change': '您的余额与投注金额过于接近:还需要 {required_extra_plm} PLM,交易才能保留找零输出,并在网络拥堵时提高手续费重新广播。',
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
'error.round_not_found': '未找到该轮次。',
'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。',
'error.current_password_incorrect': '当前密码不正确。',
'error.password_too_short': '新密码长度至少需要 {minimum} 个字符。',
@@ -1114,9 +1168,11 @@ const TRANSLATIONS = {
'error.invalid_request': '请求无效,请检查填写的内容。',
'error.invalid_amount': '请输入大于零的金额。',
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。',
'error.amount_below_dust_limit': '金额过小,无法发送。',
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
'error.withdrawal_to_platform_address': '这是平台自己的地址 — 请提现到外部钱包。',
'error.internal_error': '服务器发生意外错误,请稍后重试。',
'error.guide_unavailable': '指南当前不可用。',
'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。',
+9 -2
View File
@@ -83,7 +83,10 @@
<div class="maintenance-banner hidden" id="maintenance-banner">
<span>⚠️</span>
<span data-i18n="maintenance.banner">Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.</span>
<!-- Filled by renderChainStatusBar(): the text depends on *why* no round will
open — a deliberate pause, or an instance the operator hasn't finished
configuring (B-66) — so it renders through t() and carries no data-i18n. -->
<span id="maintenance-banner-text"></span>
</div>
<section id="landing-hero" class="hero">
@@ -150,11 +153,15 @@
<div class="row-between" style="margin-top:10px" id="round-stats-row">
<div>
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
<span class="mono" id="round-players">—</span>
<!-- The two -pending spans are filled from server data by renderRoundStats()
(B-65), so they carry no data-i18n: an element belongs to one
translation mechanism or the other, never both. -->
<span class="mono" id="round-players">—</span> <span class="pending-suffix hidden" id="round-players-pending"></span>
</div>
<div style="text-align:right">
<div class="hint" style="margin-bottom:2px" data-i18n="round.jackpot">Jackpot</div>
<span class="mono" id="round-jackpot">—</span> <span class="balance-unit">PLM</span>
<span class="pending-suffix hidden" id="round-jackpot-pending"></span>
</div>
</div>
+6
View File
@@ -215,6 +215,12 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
.balance-confirmed { color: var(--color-success); }
.balance-pending { color: var(--color-primary); }
/* The in-flight part of the round's own figures (B-65): the players/jackpot next
to it are the confirmed ones the draw and the payout will actually use, and this
is what has been bet but hasn't confirmed yet. Same amber as .balance-pending,
for the same "not settled" meaning. */
.pending-suffix { color: var(--color-primary); font-size: 0.8rem; font-weight: 600; }
.icon { width: 16px; height: 16px; flex-shrink: 0; }
.dash-panel { display: none; }
+123 -24
View File
@@ -6,13 +6,24 @@ from embit import script
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from embit.finalizer import finalize_psbt
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.db.base import begin_immediate
from app.db.models import (
PendingTransaction,
PendingTransactionTxid,
Round,
RoundParticipant,
User,
UtxoEvent,
Withdrawal,
)
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.wallet.hd import derive_pool_key, derive_user_key
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
@@ -48,12 +59,18 @@ def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple:
async def _signing_context(
session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None
) -> tuple:
"""Returns (signing_key, own_script, own_address) for the single sender that
controls every input of this tx — a user for bet/withdrawal, the pool for
payout. All our builders only ever spend one address's UTXOs per tx."""
payout (the round's own pool key, B-80). All our builders only ever spend one
address's UTXOs per tx."""
if kind == "payout":
key = derive_pool_key()
round_ = await session.get(Round, round_id) if round_id is not None else None
if round_ is None or not round_.pool_address:
raise RbfError(f"payout of round {round_id}: the round has no pool address")
key = derive_round_pool_key(round_.id, round_.pool_address)
else:
user = await session.get(User, user_id)
key = derive_user_key(user.derivation_index)
@@ -86,28 +103,42 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
async def bump_fee(
session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
session_factory: async_sessionmaker,
client: ElectrumClient,
pending_id: int,
network: TransactionNetwork | None = None,
) -> str | None:
"""Rebuild pending_transaction `pending_id`'s transaction with a higher fee
(same inputs, same recipient outputs, the extra fee taken from the change
output) and rebroadcast. Returns the new txid, or None if there was nothing
to do (the row is gone or already left "pending" — a normal race with
confirmation, not an error).
confirmation, not an error, or a conflict recorded below).
Three phases, so no DB session is held across the network calls this needs
Four phases, so no DB session is held across the network calls this needs
(one get_transaction per input, then a broadcast) — the same shape used
elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout;
B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's
needed and close the session, do the chain work, then reopen to persist.
B-77 (TX-12, TX-13, DAT-34): the replacement is **written before it is broadcast**.
It used to be sent in the chain phase and recorded afterwards, unconditionally, so
a crash or a `database is locked` in between left a transaction on the network
that the database did not know, and an abandon or a confirmation that happened in
the meantime was silently overwritten. Now the signed replacement is committed as
a *candidate* version (pending_transaction_txids) first; a candidate that is never
broadcast is harmless, and one that is — and then confirms — is recognised like any
other version. After the broadcast the row is switched over with a conditional
UPDATE (status still pending, current_txid still the one replaced); zero rows
matched is a conflict: the row is left alone, the candidate stays recorded, and
the reconciler resolves it.
Only handles the common case: exactly one change output paying back to the
tx's own sender address, large enough to absorb the increase. If there's no
such output (e.g. an exact-amount bet with no change), this raises RbfError —
bumping such a tx would require selecting additional inputs, which isn't
implemented for the MVP; it needs manual operator intervention. Also raises
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
(B-32) — the reconciler abandons it if it never confirms (B-27), instead of
this retrying an ever-higher fee forever.
(B-32), and when the network explicitly refuses the replacement (TX-24).
"""
# --- Phase 1: read what's needed, close the session before any network call ---
async with session_factory() as session:
@@ -124,9 +155,10 @@ async def bump_fee(
kind = pending.kind
current_fee_rate = pending.fee_rate_sat_vb
raw_tx_hex = pending.raw_tx_hex
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id)
old_txid = pending.current_txid
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id, pending.round_id)
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
# --- Phase 2: chain reads and signing — no DB session open, nothing sent yet ---
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
total_in = sum(input_amounts)
@@ -171,26 +203,69 @@ async def bump_fee(
raw_hex = final_tx.serialize().hex()
new_txid = final_tx.txid().hex()
await client.broadcast(raw_hex)
# --- Phase 3: persist the outcome ----------------------------------------------
# --- Phase 3: record the replacement as a candidate, *then* broadcast ----------
async with session_factory() as session:
await begin_immediate(session) # B-88
pending = await session.get(PendingTransaction, pending_id)
old_txid = pending.current_txid
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
pending.current_txid = new_txid
pending.raw_tx_hex = raw_hex
if pending is None or pending.status != "pending" or pending.current_txid != old_txid:
logger.info("pending_transaction %s changed while its bump was being built; skipping", pending_id)
return None
await record_version(session, pending, new_txid, raw_hex) # B-77 (TX-12, DAT-71)
await session.commit()
verdict = await broadcast_with_verdict(client, network, raw_hex, new_txid) # B-76: also fans out
if verdict.outcome is BroadcastOutcome.REFUSED:
# The network explicitly refused the replacement and no server knows it: the row
# is unchanged and keeps the version that is on the network (TX-24). The
# candidate stays recorded — it was never relayed, so nobody will confirm it.
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
# follows it (TX-12); the old version stays recorded and is still recognised should
# it be the one that confirms.
# --- Phase 4: switch the row over, conditionally --------------------------------
async with session_factory() as session:
await begin_immediate(session) # B-88
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
# floor above raised fee_delta past the naive target, the tx now pays more
# than target_fee_rate implied. Recording the true rate keeps the next bump's
# arithmetic honest instead of drifting from what's really being paid.
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
pending.attempt_count += 1
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
# from it — overwriting it here used to reset that clock on every bump, so a
# repeatedly-bumped-but-never-mined tx was never abandoned.
pending.last_broadcast_at = datetime.now(timezone.utc)
result = await session.execute(
update(PendingTransaction)
.where(
PendingTransaction.id == pending_id,
PendingTransaction.status == "pending",
PendingTransaction.current_txid == old_txid,
)
.values(
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
current_txid=new_txid,
raw_tx_hex=raw_hex,
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
attempt_count=PendingTransaction.attempt_count + 1,
last_broadcast_at=datetime.now(timezone.utc),
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
# Confirmed, abandoned or already re-bumped while we were broadcasting. The
# row is not overwritten; the candidate is still recorded, and confirmation
# and reconciliation look for it like any other version (TX-13).
await session.rollback()
logger.warning(
"bump of pending_transaction %s: the row changed during the broadcast (replaced %s by %s); "
"left to the reconciler",
pending_id,
old_txid,
new_txid,
)
return None
pending = await session.get(PendingTransaction, pending_id)
await _retarget_txid_references(session, pending, old_txid, new_txid)
await session.commit()
@@ -198,6 +273,29 @@ async def bump_fee(
return new_txid
async def adopt_version(session: AsyncSession, pending: PendingTransaction, txid: str) -> None:
"""The network confirmed (or holds) a version of this row other than the current one
— an earlier version of a bumped transaction, or a candidate whose switch-over never
happened (B-77, TX-19, DAT-14). `current_txid` and `raw_tx_hex` become that
version's, and the txid is propagated everywhere it is recorded, exactly as after a
bump; without it the inputs of a replaced transaction would no longer be
recognised as the row's (DAT-13)."""
if txid == pending.current_txid:
return
version = await session.scalar(
select(PendingTransactionTxid).where(
PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == pending.id
)
)
old_txid = pending.current_txid
pending.replaced_by_txid = old_txid
pending.current_txid = txid
if version is not None and version.raw_hex:
pending.raw_tx_hex = version.raw_hex # empty only for a migration-reconstructed txid
await _retarget_txid_references(session, pending, old_txid, txid)
logger.warning("pending_transaction %s: version %s found on the network, replacing %s", pending.id, txid, old_txid)
async def _retarget_txid_references(
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
) -> None:
@@ -239,9 +337,10 @@ async def _retarget_txid_references(
class RbfBumper:
def __init__(self, session_factory: async_sessionmaker, get_client):
def __init__(self, session_factory: async_sessionmaker, get_client, network: TransactionNetwork | None = None):
self._session_factory = session_factory
self._get_client = get_client
self._network = network # B-76/B-77: verifies and fans out a replacement
async def run(self) -> None:
while True:
@@ -266,7 +365,7 @@ class RbfBumper:
for pending_id in due_ids:
try:
await bump_fee(self._session_factory, client, pending_id)
await bump_fee(self._session_factory, client, pending_id, network=self._network)
except RbfError:
logger.exception("could not bump pending_transaction %s", pending_id)
except Exception:
+57 -10
View File
@@ -1,15 +1,19 @@
import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import begin_immediate
from app.db.models import PendingTransaction
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.tx.broadcast import adopt_version
from app.tx.pending_address import own_address_for
from app.tx.versions import txids_by_row
logger = logging.getLogger(__name__)
@@ -26,7 +30,19 @@ def register_handler(kind: str, handler: ConfirmationHandler) -> None:
_handlers[kind] = handler
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
class ConfirmationProver(Protocol):
"""The part of ElectrumListener a confirmation needs (B-83): the quorum's verdict on
a merkle proof of `txid` at `height`."""
async def confirmation_proven(self, txid: str, height: int) -> bool: ...
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient, network: ConfirmationProver | None) -> int:
"""One pass over the `pending` rows. The active server's history only nominates a
candidate: a row is confirmed when the quorum proves it with a merkle proof
(B-83, TX-22). With no `network` to ask nothing can be confirmed — the active
server's word alone is exactly what a hostile one would use to declare a payout that
was never broadcast confirmed."""
async with session_factory() as session:
# Plain columns, not entities: nothing then outlives the session, so this
# can't break if expire_on_commit is ever turned on (B-21).
@@ -37,24 +53,28 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
PendingTransaction.current_txid,
PendingTransaction.kind,
PendingTransaction.user_id,
PendingTransaction.round_id,
).where(PendingTransaction.status == "pending")
)
).all()
# Resolved once per candidate while the session is still open, and cached
# by scripthash below — every "payout" row shares the same pool address,
# so this also avoids asking the server the same history twice per tick.
# by scripthash below — the payout rows of one round share its pool
# address (B-80), so this also avoids asking the server the same history twice.
scripthash_by_id: dict[int, str] = {}
for pending_id, _txid, kind, user_id in candidates:
# B-77 (TX-06, TX-19): every txid a row ever had, not only the current one — an
# earlier version of a bumped transaction may be the one that confirms.
txids_by_id = await txids_by_row(session, {pending_id: txid for pending_id, txid, *_ in candidates})
for pending_id, _txid, kind, user_id, round_id in candidates:
try:
address = await own_address_for(session, kind, user_id)
address = await own_address_for(session, kind, user_id, round_id)
scripthash_by_id[pending_id] = address_to_scripthash(address)
except Exception:
logger.exception("could not derive the address for pending_transaction %s", pending_id)
confirmed = 0
history_cache: dict[str, list[dict]] = {}
for pending_id, txid, kind, _user_id in candidates:
for pending_id, txid, kind, _user_id, _round_id in candidates:
scripthash = scripthash_by_id.get(pending_id)
if scripthash is None:
continue # address derivation failed above; already logged
@@ -72,17 +92,38 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
continue
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
known_txids = txids_by_id[pending_id]
# height > 0 means confirmed at that height; 0 or absent means still in
# the mempool (or the server doesn't know this txid at all yet) — either
# way, not confirmed, so keep waiting.
if entry is None or entry.get("height", 0) <= 0:
entry = next(
(e for e in history_cache[scripthash] if e.get("tx_hash") in known_txids and e.get("height", 0) > 0),
None,
)
if entry is None:
continue
# B-83 (TX-22, ELC-26): the height is a claim of the active server. The quorum has
# to return a valid merkle proof of the txid against that block's header; without
# one the row stays pending and is looked at again on the next pass.
if network is None or not await network.confirmation_proven(entry["tx_hash"], entry["height"]):
logger.warning(
"pending_transaction %s: %s reported at height %s but the quorum did not prove it — still pending",
pending_id,
entry["tx_hash"],
entry["height"],
)
continue
async with session_factory() as session:
await begin_immediate(session) # B-88: read the row and write its outcome under one lock
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":
continue
if entry["tx_hash"] != row.current_txid:
# The confirmed version is not the current one (B-77, DAT-14): realign
# the row and everything that recorded its txid *before* the handler runs.
await adopt_version(session, row, entry["tx_hash"])
row.status = "confirmed"
handler = _handlers.get(kind)
if handler is not None:
@@ -95,16 +136,22 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
class ConfirmationPoller:
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
def __init__(
self,
session_factory: async_sessionmaker,
get_client: Callable[[], ElectrumClient | None],
network: ConfirmationProver | None = None,
):
self._session_factory = session_factory
self._get_client = get_client
self._network = network # the ElectrumListener, which holds the headers and asks the quorum
async def run(self) -> None:
while True:
client = self._get_client()
if client is not None:
try:
await poll_once(self._session_factory, client)
await poll_once(self._session_factory, client, self._network)
except asyncio.CancelledError:
raise
except Exception:
+101
View File
@@ -0,0 +1,101 @@
"""What a broadcast attempt amounted to (B-76, TX-04, TX-20, TX-21).
Money may only be released on proof that a transaction never left. The text of an
Electrum error cannot give that proof (servers word "already in the mempool" in many
ways and there is no standard code), and a timeout or a dropped connection says
nothing about whether the server acted before it went quiet. So a broadcast ends in
one of three ways, and only one of them frees coins:
ACCEPTED the server answered with the txid, or refused but at least one server of
the network (the active one or any other) returns the transaction;
REFUSED the server answered with an explicit JSON-RPC error *and* the active
server plus the quorum minimum of the others all say they do not know the
transaction;
UNCERTAIN anything else — a timeout, a dropped connection, a write error, a
refusal whose verification could not get enough answers.
"""
import logging
from enum import Enum
from typing import NamedTuple, Protocol
from app.electrum.client import ElectrumRpcError
logger = logging.getLogger(__name__)
# Messages by which a node says it already has the transaction. Recognising one skips
# the verification below; *not* recognising a message never releases anything, it only
# costs one more round of questions (TX-04).
_ALREADY_KNOWN_MARKERS = (
"already known",
"already in mempool",
"already in the mempool",
"txn-already-known",
"txn-already-in-mempool",
"already in block chain",
"already in blockchain",
)
class BroadcastOutcome(Enum):
ACCEPTED = "accepted"
REFUSED = "refused"
UNCERTAIN = "uncertain"
class BroadcastVerdict(NamedTuple):
outcome: BroadcastOutcome
reason: str # the server's own wording or the failure's, for logs and audit rows
class TransactionNetwork(Protocol):
"""The part of ElectrumListener a broadcast needs (see electrum/listener.py)."""
async def transaction_known(self, txid: str) -> bool | None: ...
def fan_out_transaction(self, raw_tx_hex: str) -> None: ...
def is_already_known(message: str) -> bool:
lowered = message.lower()
return any(marker in lowered for marker in _ALREADY_KNOWN_MARKERS)
async def broadcast_with_verdict(client, network: TransactionNetwork | None, raw_tx_hex: str, txid: str) -> BroadcastVerdict:
"""Broadcast `raw_tx_hex` through the active `client` and say what happened.
With no `network` to ask, an explicit refusal cannot be verified and therefore
comes back UNCERTAIN: the doubt never frees coins. An accepted transaction is
sent to every other server too (best effort, TX-21)."""
try:
await client.broadcast(raw_tx_hex)
except ElectrumRpcError as exc:
reason = exc.message
if is_already_known(reason):
known: bool | None = True
elif network is None:
known = None
else:
known = await network.transaction_known(txid)
if known is True:
_fan_out(network, raw_tx_hex)
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, reason)
if known is False:
return BroadcastVerdict(BroadcastOutcome.REFUSED, reason)
logger.warning("broadcast of %s was refused (%s) but the refusal could not be verified", txid, reason)
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, reason)
except Exception as exc:
# Timeout, dropped connection, write error: the server may have relayed it.
logger.warning("broadcast of %s ended without an answer (%r): outcome uncertain", txid, exc)
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, str(exc) or type(exc).__name__)
_fan_out(network, raw_tx_hex)
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, "")
def _fan_out(network: TransactionNetwork | None, raw_tx_hex: str) -> None:
if network is None:
return
try:
network.fan_out_transaction(raw_tx_hex)
except Exception:
logger.exception("could not start the fan-out of an accepted transaction")
+11 -5
View File
@@ -1,12 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User
from app.wallet.hd import derive_pool_address, derive_user_address
from app.db.models import Round, User
from app.wallet.hd import derive_user_address
async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str:
async def own_address_for(
session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None
) -> str:
"""The address that owns every input of a PendingTransaction of this kind —
a user's own address for a bet/withdrawal, the pool address for a payout.
a user's own address for a bet/withdrawal, the pool address of the round for a
payout (B-80: each round has its own, stored on the round).
All our builders only ever spend one address's UTXOs per tx (see
tx/broadcast.py:_signing_context, which derives the same address alongside
the signing key it also needs).
@@ -17,6 +20,9 @@ async def own_address_for(session: AsyncSession, kind: str, user_id: int | None)
different addresses for the same row.
"""
if kind == "payout":
return derive_pool_address()
round_ = await session.get(Round, round_id) if round_id is not None else None
if round_ is None or not round_.pool_address:
raise ValueError(f"payout of round {round_id}: the round has no pool address")
return round_.pool_address
user = await session.get(User, user_id)
return derive_user_address(user.derivation_index)
+262 -50
View File
@@ -13,15 +13,26 @@ balance simply lost them, with no path back short of editing the DB by hand
What it does, per PendingTransaction that isn't already terminal:
* status "building" — we crashed (or were killed) between writing the row and
broadcasting. Ask the chain: if the tx is there after all, promote everything
to its live state; if it isn't, release the UTXOs and undo the intent.
broadcasting. Ask the network: if any server knows one of the row's txids,
promote everything to its live state.
* status "pending" — broadcast, still unconfirmed. Left alone until it has been
unconfirmed for `_ABANDON_AFTER_SECONDS`, since absence from one server's
mempool is not proof of death; only then is it abandoned like the above.
unconfirmed for `_ABANDON_AFTER_SECONDS`, and even then only examined.
Deliberately conservative: it never touches a tx the chain knows about, and the
grace period is long (multiples of the RBF timeout) so a slow-but-alive tx is
bumped by RbfBumper rather than abandoned here.
and then *nothing is given up without proof* (B-78, ADR-51, TX-17). A row is
abandoned — its coins released — only when
(a) one of its inputs is spent **in a block** by a transaction that is none of its
own versions, and the other servers agree; or
(b) no server of the quorum knows any txid of the row **and** sending its latest
raw transaction to every server is refused, explicitly and by all of them, for
missing or already-spent inputs.
Absence from one server's history proves nothing — after a restart onto a different
server that is the normal state of a tx the new server has not seen — and neither
does the age of the row. A refusal for fee reasons is no proof either: such a row is
promoted (building) or left to the RBF bumper (pending). A transport failure never
resolves anything. Failed rows stay watched for `FAILED_WATCH_DAYS`: if one of them
confirms after all, the lottery is paused for the operator (TX-23).
"""
import asyncio
@@ -34,11 +45,24 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
from app.db.base import begin_immediate
from app.db.models import (
PendingTransaction,
PendingTransactionTxid,
Round,
RoundParticipant,
UtxoEvent,
Withdrawal,
)
from app.electrum.client import ElectrumClient
from app.electrum.quorum import ServerAnswers
from app.electrum.scripthash import address_to_scripthash
from app.rounds.events import broadcaster
from app.rounds.pause import pause_automatically
from app.tx.broadcast import adopt_version
from app.tx.outcome import is_already_known
from app.tx.pending_address import own_address_for
from app.tx.versions import txids_by_row, txids_of
from app.wallet.balance import recompute_balance
logger = logging.getLogger(__name__)
@@ -50,14 +74,32 @@ _POLL_INTERVAL_SECONDS = 120
_BUILDING_GRACE_SECONDS = 120
# A "pending" row was accepted by a node once. Give it a wide margin — the RBF
# bumper gets several attempts inside this window — before concluding it's gone.
# bumper gets several attempts inside this window — before it is even examined.
_ABANDON_AFTER_SECONDS = 6 * 60 * 60
# How long a failed row keeps being looked at (B-78, DAT-72): a transaction given up
# for dead can still arrive in a block.
FAILED_WATCH_DAYS = 14
# How many distinct (txid, height) claims of one failed row are put to the quorum per pass.
_MAX_RESURRECTION_CLAIMS = 3
# What a node says when the transaction's inputs do not exist or are spent already.
_MISSING_INPUTS_MARKERS = ("missing-inputs", "missingorspent", "missing inputs", "inputs-missing")
# What it says when only the fee is the problem: not proof of anything.
_FEE_MARKERS = ("min relay fee", "mempool min fee", "insufficient fee", "fee not met", "fee too low", "fee rate")
class PendingTransactionReconciler:
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
def __init__(
self,
session_factory: async_sessionmaker,
get_client: Callable[[], ElectrumClient | None],
network=None,
):
self._session_factory = session_factory
self._get_client = get_client
self._network = network # the ElectrumListener: histories of every server, rebroadcast, spenders
async def run(self) -> None:
# Runs once promptly at startup: a crash mid-broadcast is exactly the case
@@ -66,7 +108,7 @@ class PendingTransactionReconciler:
client = self._get_client()
if client is not None:
try:
await reconcile_once(self._session_factory, client)
await reconcile_once(self._session_factory, client, self._network)
except asyncio.CancelledError:
raise
except Exception:
@@ -74,81 +116,246 @@ class PendingTransactionReconciler:
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
"""Returns how many rows were resolved (promoted or abandoned).
class _Candidate:
__slots__ = ("row_id", "status", "txid", "scripthash", "known_txids", "raw_hex")
Existence is decided by checking whether a row's own address's history
(blockchain.scripthash.get_history) includes its txid at all — mempool or
mined — rather than asking blockchain.transaction.get for a verbose reply
(B-41): several Electrum server implementations and versions reject the
verbose flag outright, and the previous substring-matching on the error
text (looking for "missing", "not found", ...) was fragile as the basis for
a decision that releases funds. A transport failure fetching history still
raises and leaves the row alone until next time — get_history not
returning our txid is the only thing that means "gone". History is cached
per scripthash within one pass, since every "payout" row shares the same
pool address.
def __init__(self, row_id, status, txid, scripthash, known_txids, raw_hex):
self.row_id = row_id
self.status = status
self.txid = txid
self.scripthash = scripthash
self.known_txids = known_txids
self.raw_hex = raw_hex
async def reconcile_once(
session_factory: async_sessionmaker, client: ElectrumClient, network=None
) -> int:
"""Returns how many rows were resolved (promoted, abandoned, realigned or
resurrected).
Existence is decided by asking every server of the quorum for the history of the
row's own address (blockchain.scripthash.get_history) and looking for **every** txid
the row has had, mempool or mined (B-41, B-77, B-78) — never for a verbose
blockchain.transaction.get reply, which several servers reject outright, and never
on error text. History is cached per scripthash within one pass, since the payout
rows of one round share its pool address (B-80). With no `network` only the active
server is asked, which can promote a row but never abandon one.
"""
now = datetime.now(timezone.utc)
async with session_factory() as session:
rows = (
await session.scalars(
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending")))
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending", "failed")))
)
).all()
candidates = []
txids_by_id = await txids_by_row(session, {row.id: row.current_txid for row in rows})
candidates: list[_Candidate] = []
for row in rows:
if not _is_due(row, now):
continue
try:
address = await own_address_for(session, row.kind, row.user_id)
address = await own_address_for(session, row.kind, row.user_id, row.round_id)
scripthash = address_to_scripthash(address)
except Exception:
logger.exception("could not derive the address for pending_transaction %s", row.id)
continue
candidates.append((row.id, row.status, row.current_txid, scripthash))
candidates.append(
_Candidate(row.id, row.status, row.current_txid, scripthash, txids_by_id[row.id], row.raw_tx_hex)
)
resolved = 0
history_cache: dict[str, list[dict]] = {}
for row_id, status, txid, scripthash in candidates:
history_cache: dict[str, ServerAnswers] = {}
for candidate in candidates:
try:
if scripthash not in history_cache:
history_cache[scripthash] = await client.get_history(scripthash)
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
if candidate.scripthash not in history_cache:
history_cache[candidate.scripthash] = await _histories(client, network, candidate.scripthash)
answers = history_cache[candidate.scripthash]
decision = await _decide(network, candidate, answers)
except Exception:
# Transport/server problem — say nothing about this tx and try again on
# the next pass rather than abandoning a tx that may be perfectly alive.
logger.warning("could not check pending_transaction %s (txid %s) against the chain", row_id, txid)
logger.warning(
"could not check pending_transaction %s (txid %s) against the chain",
candidate.row_id,
candidate.txid,
exc_info=True,
)
continue
if decision is None:
continue
async with session_factory() as session:
row = await session.get(PendingTransaction, row_id)
if row is None or row.status != status:
continue # something else moved it while we were asking
if exists:
await begin_immediate(session) # B-88: the re-check and the resolution under one lock
row = await session.get(PendingTransaction, candidate.row_id)
# TX-26: the status *and* the current txid must still be what they were when
# the network was asked — a confirmation or an RBF in the meantime changes
# what the answer means.
if row is None or row.status != candidate.status or row.current_txid != candidate.txid:
continue
kind, payload = decision
changed = False
if kind == "resurrected":
await _resurrect(session, row, *payload)
changed = True
elif kind == "present":
if payload and payload != row.current_txid:
# Another version of the row is the one on the network (B-77, TX-19):
# the row follows it, so the inputs and the domain row point at it.
await adopt_version(session, row, payload)
changed = True
if row.status == "building":
await _promote(session, row)
resolved += 1
else:
await _abandon(session, row, "not found on chain")
resolved += 1
changed = True
elif kind == "promote":
await _promote(session, row)
changed = True
elif kind == "abandon":
await _abandon(session, row, payload)
changed = True
await session.commit()
resolved += changed
broadcaster.publish()
return resolved
async def _histories(client: ElectrumClient, network, scripthash: str) -> ServerAnswers:
if network is not None:
return await network.get_histories(scripthash)
# No quorum to ask: the active server alone can show a transaction exists, but its
# silence can never be taken for absence (`complete` stays false).
return ServerAnswers(await client.get_history(scripthash), (), 1)
def _entries_for(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[str, int, bool]]:
"""(txid, height, from_active) for every history entry of every answering server that
is one of the row's txids."""
found: list[tuple[str, int, bool]] = []
if answers.active is not None:
found += [(e["tx_hash"], e.get("height") or 0, True) for e in answers.active if e.get("tx_hash") in known_txids]
for history in answers.answered_others:
found += [(e["tx_hash"], e.get("height") or 0, False) for e in history if e.get("tx_hash") in known_txids]
return found
def _confirmed_candidates(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[str, int]]:
"""(txid, height) of every txid of the row that any answering server lists in a block,
the one most servers agree on first. They are only *claims*: each is proven by the
quorum's merkle proofs before it is believed (B-83, TX-22)."""
votes: dict[tuple[str, int], int] = {}
for history in answers.all:
for entry in history:
if entry.get("tx_hash") in known_txids and (entry.get("height") or 0) > 0:
key = (entry["tx_hash"], entry["height"])
votes[key] = votes.get(key, 0) + 1
return [key for key, _ in sorted(votes.items(), key=lambda item: -item[1])]
async def _decide(network, candidate: _Candidate, answers: ServerAnswers):
"""What the network says about one row, as ("present", txid | None), ("promote", None),
("abandon", reason), ("resurrected", (txid, height)) or None for "nothing to do or
not enough to tell". Pure reads — nothing is written here."""
if candidate.status == "failed":
if network is None:
return None # nothing can be proven without the quorum
for txid, height in _confirmed_candidates(answers, candidate.known_txids)[:_MAX_RESURRECTION_CLAIMS]:
if await network.confirmation_proven(txid, height):
return ("resurrected", (txid, height))
return None
found = _entries_for(answers, candidate.known_txids)
if found:
# Prefer a version that is mined, then the current one, then any.
mined = [txid for txid, height, _ in found if height > 0]
present = {txid for txid, _, _ in found}
chosen = mined[0] if mined else (candidate.txid if candidate.txid in present else found[0][0])
return ("present", chosen)
if not answers.complete:
logger.warning(
"pending_transaction %s (txid %s): too few servers answered to say the tx is unknown — left as is",
candidate.row_id,
candidate.txid,
)
return None
if network is None:
return None
# No server of the quorum knows any txid of the row. That alone proves nothing (TX-15).
tx = Transaction.parse(bytes.fromhex(candidate.raw_hex))
outpoints = {(vin.txid.hex(), vin.vout) for vin in tx.vin}
spender = await network.confirmed_spender(candidate.scripthash, candidate.known_txids, outpoints)
if spender is not None:
return ("abandon", f"input spent in a block by {spender}")
rebroadcast: ServerAnswers = await network.rebroadcast_everywhere(candidate.raw_hex)
verdicts = rebroadcast.all
if any(v[0] == "accepted" or (v[0] == "refused" and is_already_known(v[1])) for v in verdicts):
# It was alive after all (or is again): the same as having found it.
return ("present", None)
refusals = [v[1].lower() for v in verdicts if v[0] == "refused"]
if rebroadcast.complete and len(refusals) == len(verdicts) and all(
any(marker in message for marker in _MISSING_INPUTS_MARKERS) for message in refusals
):
return ("abandon", f"rebroadcast refused for missing inputs: {refusals[0]}")
if candidate.status == "building" and any(any(marker in m for marker in _FEE_MARKERS) for m in refusals):
return ("promote", None) # not proof: let the RBF bumper accelerate it (TX-17)
return None
def _is_due(row: PendingTransaction, now: datetime) -> bool:
if row.status == "failed":
# B-78 (TX-23, DAT-72): watched every pass for FAILED_WATCH_DAYS from failed_at.
failed_at = row.failed_at or row.updated_at
return failed_at is not None and now < failed_at.replace(tzinfo=timezone.utc) + timedelta(days=FAILED_WATCH_DAYS)
# Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an
# RBF bump used to overwrite this same field, which reset this grace period on
# every bump and meant a repeatedly-bumped-but-never-mined tx was never
# abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches
# last_broadcast_at, so this keeps measuring from when the tx first appeared,
# no matter how many times it's since been bumped.
# no matter how many times it's since been bumped. The period only decides from
# when a row is *examined*; giving it up still needs proof (TX-14, TX-17).
grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS
return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace)
async def _resurrect(session: AsyncSession, row: PendingTransaction, txid: str, height: int) -> None:
"""A row given up for dead was mined (B-78, TX-23, DAT-72). The row becomes
`confirmed` and the lottery is paused for the operator: the domain rows the
abandonment already rolled back (a deleted participant, a failed withdrawal, released
coins, a rebuilt payout) are *not* put back by the system, which cannot know what has
happened to them since."""
if txid != row.current_txid:
version_raw = None
version = await session.scalar(
select(PendingTransactionTxid).where(
PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == row.id
)
)
if version is not None and version.raw_hex:
version_raw = version.raw_hex
row.current_txid = txid
if version_raw:
row.raw_tx_hex = version_raw
row.status = "confirmed"
await write_audit_log(
session,
"pending_tx_resurrected",
{"pending_transaction_id": row.id, "kind": row.kind, "txid": txid, "block_height": height},
user_id=row.user_id,
round_id=row.round_id,
)
await pause_automatically(session, "resurrected_tx")
logger.error(
"%s pending_transaction %s was given up as failed but tx %s is in block %s — lottery paused for the operator",
row.kind,
row.id,
txid,
height,
)
async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
"""The tx did make it onto the chain before we died — finish what phase 2 of
place_bet/request_withdrawal would have done."""
@@ -178,10 +385,12 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) -> None:
"""The tx is gone for good. Release whatever it reserved so the funds come back,
and roll the domain row back to something truthful."""
reason = reason[:128]
row.status = "failed"
row.failure_reason = reason[:128]
row.failure_reason = reason
row.failed_at = datetime.now(timezone.utc) # B-78: starts the 14-day watch
released = await _release_inputs(session, row)
released = await _release_inputs(session, row, await txids_of(session, row))
if row.kind == "bet":
participant = await session.scalar(
@@ -202,8 +411,10 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
elif row.kind == "payout":
# Payout funds come from the pool address, which isn't tracked in
# utxo_events, so there's nothing to release. Clearing payout_txid leaves the
# round in "paying_out" with no tx attached, which is the state an operator
# (or a future payout-retry routine — still an open gap) can act on.
# round in "paying_out" with no tx attached, which is exactly the state the
# scheduler's payout retry picks up (B-26: `_retry_payout_if_due`, one attempt
# per 60s), so an abandoned payout is rebuilt on its own rather than waiting
# for an operator — the log line below is the alert, not the recovery path.
round_ = await session.get(Round, row.round_id) if row.round_id else None
if round_ is not None and round_.status == "paying_out":
round_.payout_txid = None
@@ -237,7 +448,7 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
)
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
async def _release_inputs(session: AsyncSession, row: PendingTransaction, own_txids: set[str] | None = None) -> int:
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
counts them again. The inputs come from the stored raw tx, which is kept current
across RBF bumps, so this works for a bumped tx too."""
@@ -253,8 +464,9 @@ async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
)
# Only release what this tx actually reserved: if another tx has since spent
# the same UTXO, its claim is the live one and must not be cleared.
if utxo is not None and utxo.spent_txid == row.current_txid:
# the same UTXO, its claim is the live one and must not be cleared. "This tx"
# is any version of the row (B-77, DAT-13), not only its current one.
if utxo is not None and utxo.spent_txid in (own_txids or {row.current_txid}):
utxo.spent_txid = None
released += 1
return released
+53
View File
@@ -0,0 +1,53 @@
"""The versions of a pending transaction (B-77, DAT-70, DAT-71, TX-19).
A bumped transaction has several txids and any of them may be the one that confirms.
Everything that looks a transaction up on the chain asks for all of them."""
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingTransaction, PendingTransactionTxid
async def record_version(session: AsyncSession, pending: PendingTransaction, txid: str, raw_hex: str) -> None:
"""Write `txid` as a version of `pending` in the caller's transaction. Idempotent for
the same pair (a bump retried after an uncertain broadcast rebuilds the same
transaction); a txid already held by another row is an error — it is unique across
the platform."""
if pending.id is None:
await session.flush()
existing = await session.scalar(select(PendingTransactionTxid).where(PendingTransactionTxid.txid == txid))
if existing is not None:
if existing.pending_transaction_id != pending.id:
raise ValueError(f"txid {txid} already belongs to pending_transaction {existing.pending_transaction_id}")
return
session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid=txid, raw_hex=raw_hex))
async def txids_of(session: AsyncSession, pending: PendingTransaction) -> set[str]:
"""Every txid of the row: all its recorded versions plus `current_txid` (a row
written before the table existed, or by a test, may have no version row for it)."""
rows = await session.scalars(
select(PendingTransactionTxid.txid).where(PendingTransactionTxid.pending_transaction_id == pending.id)
)
return {pending.current_txid, *rows.all()}
async def txids_by_row(session: AsyncSession, current: dict[int, str]) -> dict[int, set[str]]:
"""`txids_of` for many rows at once; `current` maps row id -> current_txid."""
result = {row_id: {txid} for row_id, txid in current.items()}
if not result:
return result
rows = await session.execute(
select(PendingTransactionTxid.pending_transaction_id, PendingTransactionTxid.txid).where(
PendingTransactionTxid.pending_transaction_id.in_(list(result))
)
)
for row_id, txid in rows.all():
result[row_id].add(txid)
return result
async def delete_versions(session: AsyncSession, pending_id: int) -> None:
"""The versions go with a `bet` row that a verified refusal deletes (DAT-38)."""
await session.execute(delete(PendingTransactionTxid).where(PendingTransactionTxid.pending_transaction_id == pending_id))
+31 -8
View File
@@ -50,14 +50,37 @@ def derive_user_wif(derivation_index: int) -> str:
return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(network=PLM_MAINNET)
def derive_pool_key() -> HDKey:
"""The "indirizzo padre" from the flowchart: all bets are sent here, and
payouts are signed with this key. Reserved on branch 1 of the account (branch 0
is user addresses), index 0 — not a spec requirement, an implementation choice
to keep it in the same encrypted master key rather than a separate secret."""
return _load_account_key().derive("1/0")
def derive_pool_key(round_id: int | None = None) -> HDKey:
"""A pool key. With a `round_id`, that round's own key on branch 2 of the account
(`m/84'/746'/0'/2/<round_id>`, B-80, RND-35): its bets are paid to it and its
payout is signed with it, so one round's money and history never mix with another's.
Without one, the **reserve** key at branch 1, index 0 — the single pool address every
round used before B-80, still the pool of the rounds opened back then and the
destination of the payout's bump reserve. Branch 0 is users. Not a spec requirement
of the coin, an implementation choice to keep every key under the one encrypted
master key rather than in separate secrets."""
if round_id is None:
return _load_account_key().derive("1/0")
if round_id < 1:
raise ValueError(f"invalid round id {round_id}")
return _load_account_key().derive(f"2/{round_id}")
def derive_pool_address() -> str:
pub = derive_pool_key().to_public()
def derive_pool_address(round_id: int | None = None) -> str:
"""The pool address of a round (B-80), or the reserve address without a `round_id`."""
pub = derive_pool_key(round_id).to_public()
return script.p2wpkh(pub).address(network=PLM_MAINNET)
def derive_round_pool_key(round_id: int, pool_address: str) -> HDKey:
"""The key that controls `pool_address`, the address stored on round `round_id`.
A round opened before B-80 stores the reserve address and is signed with the reserve
key; every other round with its own. Checking the derived address against the stored
one means a key can never be used for money it does not control, and a mismatch (a
different master key, a corrupted row) fails loudly instead of building a transaction
that spends someone else's coins."""
for key in (derive_pool_key(round_id), derive_pool_key()):
if script.p2wpkh(key.to_public()).address(network=PLM_MAINNET) == pool_address:
return key
raise ValueError(f"round {round_id}: no pool key controls the stored address {pool_address!r}")
+11
View File
@@ -9,3 +9,14 @@ def encrypt_xprv(xprv_base58: str) -> bytes:
def decrypt_xprv(token: bytes) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token).decode()
def encrypt_round_seed(server_seed_hex: str) -> str:
"""B-73 (DAT-64, SEC-56): a round's secret encrypted at rest with the same key that
protects the master xprv, so a copy of the database alone never reveals it. The Fernet
token is ASCII, stored as text."""
return Fernet(settings.xprv_encryption_key).encrypt(server_seed_hex.encode()).decode()
def decrypt_round_seed(token: str) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode()
+93 -22
View File
@@ -33,14 +33,37 @@ DUST_LIMIT_SATS = 294
# eating further and further into the sender's change with no limit.
MAX_FEE_RATE_SAT_VB = 10_000
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
# stops being standard and gets refused at broadcast. Failing the build with a
# translatable error is the honest outcome; consolidating the address is the way out.
# Ceiling on how many UTXOs one *user* transaction (bet, withdrawal) may spend (B-48).
# Every extra input costs ~68 vbytes of fee, and that fee comes out of the amount being
# moved — so an address fragmented into hundreds of small deposits would silently erode
# its own bet (shrinking the user's share of the pool) or withdrawal. Failing the build
# with a translatable error is the honest outcome; consolidating the address is the way
# out. This is a *user-protection* limit, which is why the payout gets its own, far
# higher one below.
MAX_TX_INPUTS = 50
# Ceiling on the payout's inputs (B-52). The payout is not a user spending their own
# fragmented balance: it drains the pool, whose UTXO count is simply the number of bets
# in the round, and its fee comes out of a 70% share of that whole pool. So the erosion
# argument behind MAX_TX_INPUTS doesn't apply here — 400 inputs at 1 sat/vB cost ~27_300
# sat, i.e. ~0.00027 PLM out of the winner's share — and reusing that limit was what made
# any round past ~50 participants unpayable: select_utxos raised too_many_inputs, the
# round stayed "paying_out" retrying forever, and since no new round may open while one
# is active, the whole lottery stopped with the pool stuck (B-52).
#
# What actually bounds this is relay policy: a non-standard transaction is refused at
# broadcast past 100 kvB, which at ~68 vbytes per input is ~1470 inputs. 500 stays at
# roughly a third of that budget, and signing that many inputs costs ~0.4s of event loop
# (measured), once per round, inside a background task.
MAX_PAYOUT_TX_INPUTS = 500
# The most participants one round may hold (B-52). Enforced where the money is not yet
# committed — app/bets/service.py refuses the bet — instead of being discovered at payout
# time, when the bets are already in the pool and there is no way back. Deliberately below
# MAX_PAYOUT_TX_INPUTS: the payout also has to be able to spend whatever change UTXOs
# earlier rounds left in the pool, so the gap is the headroom for those.
MAX_PARTICIPANTS_PER_ROUND = 400
class InsufficientFundsError(Exception):
"""`code` is the machine-readable identifier the API layer forwards to the
@@ -59,6 +82,11 @@ class InsufficientFundsError(Exception):
self.params = params
# B-73 (DAT-63): the recipient is always the first output of a single-recipient transaction
# (the change, when there is one, follows it) — which is what a bet records as `bet_vout`.
RECIPIENT_VOUT = 0
@dataclass
class Utxo:
txid: str
@@ -80,24 +108,36 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
def select_utxos(
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering
target_sats — the amount deducted from the sender's balance. The fee is paid
out of target_sats (see build_signed_transaction), not added on top of it.
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
At most `max_inputs` are ever selected (B-48): if the largest `max_inputs`
UTXOs don't cover the target, the balance is there but too fragmented to spend
in one transaction, which is a different failure from having no funds at all
and gets its own code."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
and gets its own code.
The cap is a parameter, not the constant it used to be, because the two callers
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
eat into their own bet/withdrawal, while the payout drains a pool holding one
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
all."""
# B-75: a UTXO below the dust limit is never offered, even if one is already on
# record: it costs more to spend than it is worth.
ordered = sorted(
(u for u in utxos if u.amount_sats >= DUST_LIMIT_SATS), key=lambda u: u.amount_sats, reverse=True
)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
if len(selected) == MAX_TX_INPUTS:
if len(selected) == max_inputs:
raise InsufficientFundsError(
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
f"balance too fragmented: more than {max_inputs} inputs would be needed",
code="too_many_inputs",
max_inputs=MAX_TX_INPUTS,
max_inputs=max_inputs,
)
selected.append(utxo)
total += utxo.amount_sats
@@ -115,6 +155,7 @@ def build_signed_transaction(
amount_sats: int,
change_address: str,
fee_rate_sat_vb: int,
reduce_amount_to_keep_change: bool = False,
) -> BuiltTransaction:
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
back to change_address.
@@ -124,21 +165,47 @@ def build_signed_transaction(
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender.
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
back to ourselves would produce an unrelayable transaction. The fee estimate
already assumes two outputs, so dropping one never underpays.
B-62: the transaction always keeps a change output of at least DUST_LIMIT_SATS.
Change used to be folded into the fee whenever it came out below the dust limit,
which for an amount equal to the whole input total (the UI's "withdraw
everything" checkbox, or a bet from a balance exactly equal to the bet amount)
produced a single-output transaction — and `tx/broadcast.py:bump_fee` has nothing
to shrink there, so it raised RbfError every 30s until the reconciler abandoned
the row hours later. Adding inputs instead is no answer for this case in
particular: the transaction already spends every UTXO the sender has.
What happens when the change would be too small depends on who's asking, hence
`reduce_amount_to_keep_change`:
- withdrawals pass True — the amount moved is reduced just enough to leave a
dust-limit change output. The fee already comes out of the withdrawn amount by
design, so this is the same rule applied a little harder, and the caller
records what was actually sent (`Withdrawal.amount_sent_sats`).
- bets pass False (the default) and get an InsufficientFundsError instead: the
bet is a fixed price that cannot be quietly reduced, and "a user's balance must
never exactly equal the bet" is a documented invariant of the PLAY phase. The
player needs a little more than the bet amount, which is what the error says.
"""
selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
change = total_in - amount_sats
if change < DUST_LIMIT_SATS:
if not reduce_amount_to_keep_change:
raise InsufficientFundsError(
f"the amount leaves no change output: {DUST_LIMIT_SATS - change} more sats are "
"needed for the transaction to stay fee-bumpable",
code="balance_leaves_no_change",
required_extra_sats=DUST_LIMIT_SATS - change,
)
amount_sats -= DUST_LIMIT_SATS - change
change = DUST_LIMIT_SATS
recipient_amount = amount_sats - fee
if recipient_amount <= 0:
raise InsufficientFundsError(
"amount too small to cover the network fee", code="amount_below_network_fee"
)
change = total_in - amount_sats
if change < DUST_LIMIT_SATS:
fee += change # dust change is unspendable and unrelayable — miners get it
change = 0
if recipient_amount < DUST_LIMIT_SATS:
raise InsufficientFundsError(
"amount too small to be sent (dust)", code="amount_below_dust_limit"
@@ -203,9 +270,13 @@ def build_payout_transaction(
absorbs the tx fee — the commission (fee_address) output is untouched.
As in build_signed_transaction, dust-sized pool change is left to the fee
rather than creating an unrelayable output (B-06)."""
rather than creating an unrelayable output (B-06).
Selection uses MAX_PAYOUT_TX_INPUTS, not the much stricter user-facing
MAX_TX_INPUTS (B-52) — the pool holds one UTXO per bet, so the user-protection
cap made every round past ~50 participants impossible to pay."""
target = winner_share_sats + commission_sats
selected, total_in = select_utxos(utxos, target)
selected, total_in = select_utxos(utxos, target, max_inputs=MAX_PAYOUT_TX_INPUTS)
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
winner_amount = winner_share_sats - fee
if winner_amount < DUST_LIMIT_SATS:
+93 -10
View File
@@ -1,16 +1,23 @@
import logging
from datetime import datetime, timezone
from embit import script
from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiError
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
from app.db.base import begin_immediate
from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.tx.versions import record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.address import is_valid_plm_address
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import (
BuiltTransaction,
InsufficientFundsError,
@@ -19,12 +26,20 @@ from app.wallet.psbt_builder import (
)
logger = logging.getLogger(__name__)
class WithdrawalError(ApiError):
pass
async def request_withdrawal(
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
session: AsyncSession,
client: ElectrumClient,
user: User,
external_address: str,
amount_sats: int,
network: TransactionNetwork | None = None,
) -> Withdrawal:
# Checked before anything else: an address from another chain parses fine as a
# witness program (see wallet/address.py), so without this the tx would build,
@@ -43,7 +58,35 @@ async def request_withdrawal(
"that is your own deposit address — withdraw to an external wallet instead",
)
# B-88 (DAT-74, TX-28): the write lock before the first read that decides
# anything, so the UTXOs selected below are the ones phase 1 then marks spent.
# Taken only after the checks that need no database at all, so a malformed
# request never queues for it. No lock within busy_timeout means nothing has been
# built or broadcast: the caller is asked to retry.
try:
await begin_immediate(session)
except OperationalError as exc:
raise WithdrawalError(
"service_busy", "the service is busy right now, nothing was sent — please try again"
) from exc
config = await get_round_config(session)
# B-80 (WDR-14): never to an address of the platform itself. Coins sent to a round's
# pool address land among that round's UTXOs, and to the reserve or fee address they
# leave the user's balance for good; the amount would be spent from the balance and
# then belong to nobody. Compared lower-cased, since bech32 is case-insensitive.
platform_addresses = {derive_pool_address().lower(), config.fee_address.strip().lower()}
platform_addresses.update(
address.lower() for address in await session.scalars(select(Round.pool_address).distinct()) if address
)
platform_addresses.discard("")
if external_address.strip().lower() in platform_addresses:
raise WithdrawalError(
"withdrawal_to_platform_address",
"that is an address of the platform itself — withdraw to an external wallet instead",
)
if amount_sats < config.bet_amount_sats:
raise WithdrawalError(
"amount_below_minimum",
@@ -87,6 +130,13 @@ async def request_withdrawal(
amount_sats=amount_sats,
change_address=user.address,
fee_rate_sat_vb=config.fee_rate_sat_vb,
# B-62: "withdraw everything" asks for the whole confirmed balance, which
# would leave no change output and therefore nothing bump_fee could
# shrink — the one tx shape RBF cannot rescue, and the UI's default
# withdrawal path at that. Move a dust limit less instead of producing an
# unbumpable transaction; amount_sent_sats below records what actually
# went out, which is already how a fee-deducted withdrawal is reported.
reduce_amount_to_keep_change=True,
)
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
@@ -118,16 +168,47 @@ async def request_withdrawal(
status="building",
)
session.add(pending)
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
await session.commit()
try:
await client.broadcast(built.raw_hex)
except Exception as exc:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
raise WithdrawalError(
"broadcast_failed", f"the network refused the transaction: {exc}"
) from exc
# B-76 (TX-04, TX-20): only a verified refusal releases the coins. A timeout, a
# drop after the write, or a refusal nobody could verify may mean the transaction
# left: the withdrawal would then be paid out while its row said "failed" and its
# coins were spendable again. Those outcomes leave the rows "building".
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
if verdict.outcome is BroadcastOutcome.REFUSED:
try:
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, verdict.reason)
except OperationalError:
# B-88: the release could not be written (database busy). The rows stay
# "building", the state committed before the broadcast, and the
# reconciler resolves them against the chain.
await session.rollback()
logger.error(
"could not release the failed withdrawal %s: database busy, left to the reconciler", built.txid
)
raise WithdrawalError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
logger.warning(
"withdrawal %s: broadcast outcome uncertain (%s), left 'building' for the reconciler",
built.txid,
verdict.reason,
)
await session.refresh(withdrawal)
broadcaster.publish()
return withdrawal
# B-88: after the broadcast nothing is undone. Without the lock the rows stay
# "building" and the reconciler promotes them; the caller sees the real status.
try:
await begin_immediate(session)
except OperationalError:
await session.rollback()
logger.error(
"withdrawal %s broadcast but the database is busy: left 'building' for the reconciler", built.txid
)
await session.refresh(withdrawal)
return withdrawal
withdrawal.status = "broadcast"
pending.status = "pending"
await write_audit_log(
@@ -155,6 +236,7 @@ async def _release_failed_withdrawal(
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
withdrawal is an instruction the user gave, and they should be able to see that it
didn't go through."""
await begin_immediate(session) # B-88
for spent in built.spent_utxos:
row = await session.scalar(
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
@@ -165,6 +247,7 @@ async def _release_failed_withdrawal(
withdrawal.txid = None
pending.status = "failed"
pending.failure_reason = reason[:128]
pending.failed_at = datetime.now(timezone.utc) # B-78 (DAT-36): starts the 14-day watch
await recompute_balance(session, user_id)
await write_audit_log(
session,
+73
View File
@@ -0,0 +1,73 @@
# Documentazione di PLM Lottery
Questa cartella è la **fonte di verità** del progetto: si lavora **docs-first**
(prima si scrive o si modifica la documentazione, poi il codice) e ogni
informazione ha **un solo proprietario** (SSOT). Questo file è l'indice: da
qui si raggiunge tutto il resto.
## I tre principi
1. **Docs-first.** Nessuna modifica di comportamento parte dal codice. Si
modifica il requisito (o si scrive l'ADR, se è una scelta nuova), poi si
implementa, poi si testa. Spec, codice e test entrano nello stesso commit.
2. **SSOT — un solo proprietario per ogni informazione.** Il *perché* di una
scelta sta in un ADR; il *cosa* verificabile in un requisito
(`PREFISSO-NN`) del documento di dominio; il *come* nel codice; la cronologia
dei difetti in `storico-bug.md`. Gli altri documenti **rimandano all'ID**,
non ripetono il testo. Se due documenti dicono cose diverse, vince la
specifica e si corregge l'altro.
3. **Un ADR per ogni funzionalità.** Ogni funzionalità nuova inizia con un ADR
in [adr/](adr/README.md) (modello: [adr/TEMPLATE.md](adr/TEMPLATE.md)) che
registra contesto, decisione, alternative scartate e conseguenze, e ne
elenca i requisiti.
## Dove si trova cosa
| Domanda | Dove |
|---|---|
| Perché è fatto così? Quali alternative sono state scartate? | [adr/](adr/README.md) — indice per area e tabella funzionalità → ADR |
| Cosa deve fare esattamente il software? | I documenti di dominio qui sotto (requisiti con ID stabile) |
| Quali limiti abbiamo accettato? | [limiti-noti.md](limiti-noti.md) (`LIM-nn`) |
| Che bug sono stati trovati/corretti? Quali restano aperti? | [storico-bug.md](storico-bug.md); i soli aperti in [../BUGS.md](../BUGS.md) |
| In che ordine si implementano le correzioni aperte? | [piano-correzioni.md](piano-correzioni.md) |
| Come si modifica la documentazione/il codice? | [processo-di-modifica.md](processo-di-modifica.md) |
| Come si installa, si avvia, si usa? | Guide operative qui sotto |
## Specifica (documenti di dominio)
Punto d'ingresso narrativo: [come-funziona.md](come-funziona.md).
| Documento | Prefisso | Contenuto |
|---|---|---|
| [registrazione-e-depositi.md](registrazione-e-depositi.md) | `REG`, `DEP` | Account, indirizzi, depositi, saldo |
| [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | `BET`, `WDR` | Scommesse e prelievi |
| [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | `RND` | Apertura, chiusura, estrazione, pagamento |
| [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | `TX` | Ciclo di vita delle transazioni, RBF, riconciliazione |
| [connessione-electrum.md](connessione-electrum.md) | `ELC` | Accesso alla rete PLM e difese |
| [architettura.md](architettura.md) | `ARC` | Struttura del codice, processi, frontend, test |
| [modello-dati.md](modello-dati.md) | `DAT` | Tabelle, campi, stati, vincoli |
| [api.md](api.md) | `API` | Endpoint, risposte, codici di errore |
| [sicurezza.md](sicurezza.md) | `SEC` | Fiducia, chiavi, autenticazione, limiti sui tentativi |
| [audit-log.md](audit-log.md) | `AUD` | Catalogo degli eventi di audit |
| [operativita.md](operativita.md) | `OPS` | Backup, riavvii, incidenti |
## Decisioni, limiti, storia
| Documento | Prefisso | Contenuto |
|---|---|---|
| [adr/](adr/README.md) | `ADR` | Una decisione di progetto per file (53 al momento) |
| [limiti-noti.md](limiti-noti.md) | `LIM` | Limiti accettati consapevolmente |
| [storico-bug.md](storico-bug.md) | `B-nn` | Tutti i bug trovati e corretti |
| [piano-correzioni.md](piano-correzioni.md) | — | Ordine di implementazione delle correzioni aperte |
## Guide operative (senza requisiti)
[setup.md](setup.md) · [running-the-server.md](running-the-server.md) ·
[guida-utente.md](guida-utente.md) · [guida-admin.md](guida-admin.md). Si
aggiornano quando la specifica cambia qualcosa di visibile.
## Lingua
La documentazione di `docs/` è in italiano, per scelta esplicita; tutto il
resto del repository (codice, commenti, commit, `CLAUDE.md`, `BUGS.md`) è in
inglese.
@@ -0,0 +1,27 @@
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
- **Stato:** Accettata — implementazione da completare (B-91)
- **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**La piattaforma è custodiale: tutte le chiavi sono generate e
tenute dal server.** Una sola master xprv, cifrata con Fernet a riposo; la
chiave di cifratura sta in un file montato nel container
(`XPRV_ENCRYPTION_KEY_FILE`, permessi `0600`, vedi
[architettura.md](../architettura.md#configurazione)), mai nel database né in
git. L'utente non controlla nulla finché non preleva.
- *Motivazione:* un'esperienza da «conto» (registrazione con username e
password, scommessa con un clic) senza portafoglio lato utente, firme o
PSBT da gestire nel browser.
- *Alternative scartate:* portafoglio non custodiale con firma lato client.
- *Conseguenze:* la compromissione del server (file cifrato + variabile
d'ambiente) espone tutti i fondi; da qui l'esportazione WIF accettata
([ADR-39](ADR-39-l-esportazione-chiave-privata-wif-utente.md)), l'assenza di recupero password ([ADR-37](ADR-37-nessun-recupero-password-reset.md)) e il modello di
minaccia di [sicurezza.md](../sicurezza.md).
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
*Test:* `tests/unit/test_hd.py`
*Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la
variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`.
@@ -0,0 +1,25 @@
# ADR-02 — Un indirizzo per utente, permanente, per tutti gli usi
- **Stato:** Accettata
- **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Un indirizzo per utente, permanente, per tutti gli usi.**
Alla registrazione il server deriva un indirizzo P2WPKH BIP84
(`m/84'/746'/0'/0/<indice>`, un indice per utente, assegnato come massimo
esistente + 1). Lo stesso indirizzo è indirizzo di deposito, riceve le
vincite e riceve il resto di ogni scommessa e prelievo. Non esiste un
«indirizzo vincitore» separato.
- *Motivazione:* un solo indirizzo da osservare per utente, un solo saldo da
ricostruire (`utxo_events`), e le vincite accreditate dal normale percorso
dei depositi senza logica dedicata.
- *Conseguenze:* nessuna privacy fra depositi, vincite e resti; un prelievo
verso il proprio indirizzo è rifiutato (renderebbe indistinguibili
destinatario e resto); il pagamento di un round si considera accreditato
quando il listener vede la moneta sull'indirizzo del vincitore
(vedi [registrazione-e-depositi.md](../registrazione-e-depositi.md)).
*Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · `app/rounds/confirmation.py:_on_payout_confirmed` ·
*Test:* `tests/unit/test_hd.py`, `tests/unit/test_users.py`
@@ -0,0 +1,36 @@
# ADR-03 — Ogni round ha il proprio indirizzo del montepremi
- **Stato:** Accettata
- **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Ogni round ha il proprio indirizzo del montepremi**
(decisione del 2026-09-28), derivato dalla stessa master key sul ramo
`m/84'/746'/0'/2/<round_id>` e salvato sulla riga del round
(`rounds.pool_address`). Le scommesse di un round pagano solo il suo
indirizzo e il pagamento spende solo le monete di quell'indirizzo. Il vecchio
indirizzo unico `m/84'/746'/0'/1/0` diventa l'**indirizzo di riserva** del
montepremi, che riceve la riserva per l'RBF di ogni pagamento ([ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md));
i round aperti prima della modifica mantengono quell'indirizzo come
montepremi. Un prelievo verso un indirizzo del montepremi, di riserva o delle
commissioni è rifiutato. Il ramo `0` resta riservato agli utenti.
- *Motivazione:* con un indirizzo unico la cronologia cresce senza limite
(e con essa le risposte del server, B-75), il denaro di round diversi e le
monete estranee si mescolano, e un backup ripristinato ripete un round
contro ciò che il montepremi contiene oggi (B-90). Separare gli indirizzi
isola denaro, cronologia e pagamento di ogni round.
- *Alternative scartate:* un indirizzo unico con contabilità per round nel
database (non limita la cronologia e non isola le monete estranee);
consolidamento periodico dell'indirizzo unico (sposta il problema, non lo
toglie).
- *Conseguenze:* nessun segreto aggiuntivo (tutto deriva dalla master
key); una moneta estranea inviata all'indirizzo di un round chiuso resta
lì, visibile ma fuori da ogni pagamento; il margine fra partecipanti e
ingressi del pagamento ([ADR-21](ADR-21-tre-limiti-distinti-sul-numero-monete.md)) non si consuma più nel tempo ([LIM-17]).
Regole in [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
e [scommesse-e-prelievi.md](../scommesse-e-prelievi.md#prelievo).
*Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · `app/wallet/hd.py:derive_round_pool_key` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_round_pool_address.py`, `tests/unit/test_pending_address.py`
@@ -0,0 +1,18 @@
# ADR-04 — Accesso alla rete solo via protocollo Electrum
- **Stato:** Accettata
- **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Accesso alla rete solo via protocollo Electrum**, senza nodo
completo né P2P. Una sola connessione attiva serve tutto (accrediti, invii,
conferme, altezza della catena).
- *Motivazione:* nessun nodo da installare e sincronizzare.
- *Conseguenze:* la connessione è il principale punto singolo di guasto e il
principale rischio di integrità; da qui le difese di [ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md),
[ADR-26](ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md) e [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e i limiti [LIM-11], [LIM-12], [LIM-14]. Dettagli in
[connessione-electrum.md](../connessione-electrum.md).
*Codice:* `app/electrum/client.py:ElectrumClient` · `app/electrum/listener.py:ElectrumListener` · `app/main.py:lifespan`
@@ -0,0 +1,17 @@
# ADR-05 — Solo indirizzi bech32 PLM (`plm1…`) come destinazione
- **Stato:** Accettata
- **Area:** Custodia, chiavi e indirizzi
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Solo indirizzi bech32 PLM (`plm1…`) come destinazione.** Un
prelievo verso un indirizzo legacy (`P…`) o di un'altra catena (`bc1…`) è
rifiutato; lo stesso controllo vale per `fee_address` e per `/qr/{address}`.
- *Motivazione:* un indirizzo di un'altra catena si interpreta comunque come
witness program valido, quindi la transazione si costruirebbe e i fondi
sarebbero irrecuperabili.
*Codice:* `app/wallet/address.py:is_valid_plm_address` · `app/withdrawals/service.py:request_withdrawal` · `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_non_plm_address`
@@ -0,0 +1,40 @@
# ADR-06 — I depositi richiedono `deposit_confirmations` conferme (6 per default, configurabile in `round_config`), le uscite coinbase 120; ogni altra transazione ne richiede 1
- **Stato:** Accettata — implementazione da completare (B-82)
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**I depositi richiedono `deposit_confirmations` conferme (6
per default, configurabile in `round_config`), le uscite coinbase 120; ogni
altra transazione ne richiede 1** (decisione del 2026-09-28). «Deposito» è
una moneta arrivata all'indirizzo di un utente da una transazione che la
piattaforma non ha costruito; le uscite delle transazioni della piattaforma
(resto di scommesse e prelievi, vincite) si accreditano a 1 conferma come
le transazioni che le hanno prodotte. Le soglie si cambiano solo con una
decisione esplicita.
- *Motivazione:* il deposito è **l'unico ingresso che un utente può spendere
due volte**: tutto il resto lo firma solo il server. Un utente che
scommette con un deposito a 1 conferma e poi, se perde, lo sostituisce con
una catena privata fa sparire la propria scommessa dal montepremi mentre il
partecipante resta contato, e il round diventa impagabile per tutti
(B-82). La potenza di calcolo di PLM (~22 TH/s) si può affittare, quindi
una riorganizzazione di 1–2 blocchi è alla portata di un singolo
attaccante; 6 conferme la rendono costosa quanto sei blocchi di tutta la
rete. È anche la soglia di spendibilità del portafoglio di riferimento
([LIM-27]).
- *Alternative scartate:* 1 conferma per tutto (la regola precedente,
esposta al doppio speso del deposito); 6 conferme per tutto (allunga ogni
round di cinque blocchi per coprire transazioni che l'utente non può
sostituire); scommesse ammesse solo da monete a 6 conferme ma depositi
mostrati prima (due saldi da spiegare all'utente per lo stesso rischio).
- *Conseguenze:* un deposito diventa spendibile dopo 6 blocchi (12 minuti
in media, con blocchi da 120s) invece di 1; il rischio residuo (una riorganizzazione più profonda di 6 blocchi)
è contenuto da [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e accettato in [LIM-24]. Regole in
[registrazione-e-depositi.md](../registrazione-e-depositi.md#deposito) (B-82).
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ·
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`
*Stato:* da implementare (B-82) · *Oggi:* ogni moneta, deposito o coinbase
compresi, si accredita a 1 conferma.
@@ -0,0 +1,35 @@
# ADR-07 — Ogni decisione che sposta denaro in base a una risposta di rete è corroborata da un quorum degli altri server configurati
- **Stato:** Accettata
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Ogni decisione che sposta denaro in base a una risposta di
rete è corroborata da un quorum degli altri server configurati**: il blocco
che semina l'estrazione, una moneta data per spesa all'esterno, un nuovo
deposito da accreditare, il ripristino di una moneta, la conferma di una
transazione (con prova merkle contro un header che possediamo), l'abbandono
di una transazione, le monete del round verificate prima del pagamento. Si
interrogano solo gli *altri* server, mai quello attivo (che è ciò che un
attaccante controllerebbe); servono **almeno 2 risposte** e la maggioranza
stretta di chi risponde. Il server rifiuta di partire con meno di 2 server
di riserva, salvo `ALLOW_NO_QUORUM=true` (solo sviluppo). Se rispondono meno
di 2 server, la corroborazione fallisce.
- *Motivazione:* un solo server ostile non deve poter scegliere il
vincitore (B-28), azzerare un saldo (B-29) o gonfiarlo (B-59), né far
credere confermato un pagamento mai trasmesso (B-83). I saldi si muovono in
entrambe le direzioni, quindi entrambe richiedono il quorum. Con 0 o 1
server di riserva la «maggioranza» è una sola voce, o nessuna (B-84).
- *Alternative scartate:* quorum facoltativo, che passa sempre con la lista
vuota (la regola precedente: il default di `.env.example` spegneva ogni
difesa senza avvisare).
- *Conseguenze:* una corroborazione fallita **ritarda**, non perde e non
libera fondi ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)): il deposito è riproposto al refresh successivo,
l'estrazione richiede di nuovo lo stesso blocco. Il quorum resta fra server
scelti dall'operatore ([LIM-11]). Regole in
[connessione-electrum.md](../connessione-electrum.md#5-il-quorum) (B-83, B-84).
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.agreed_block_hash` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement` · `app/electrum/listener.py:ElectrumListener.confirmation_proven` · `app/main.py:lifespan` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`
@@ -0,0 +1,22 @@
# ADR-08 — I parametri operativi stanno nel database, i segreti nell'ambiente
- **Stato:** Accettata
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**I parametri operativi stanno nel database, i segreti
nell'ambiente.** Ogni parametro di business (costo della scommessa, durata e
pausa del round, animazione, fee rate, timeout RBF, `fee_address`, pausa) è
una colonna dell'unica riga di `round_config`, modificabile a caldo da
`/admin`; i valori iniziali sono i default delle colonne di `RoundConfig`,
non `app/config.py`. In `.env` restano solo segreti e collegamenti
(master key, JWT, host Electrum, token admin, URL del database).
- *Motivazione:* regolare la lotteria senza ridistribuire né riavviare;
i segreti richiedono comunque un riavvio e non vanno scambiati a caldo.
- *Conseguenze:* ogni modifica è validata con limiti espliciti ([ADR-09](ADR-09-modifiche-configurazione-validate-fidate.md)) e
registrata in audit come `config_updated` con prima/dopo.
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · `app/config.py:Settings` · `app/api/routes/admin.py:update_config` ·
*Test:* `tests/unit/test_admin.py::test_admin_reads_and_updates_config`, `tests/unit/test_admin.py::test_config_update_is_audit_logged`
@@ -0,0 +1,19 @@
# ADR-09 — Le modifiche di configurazione sono validate, non fidate
- **Stato:** Accettata
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Le modifiche di configurazione sono validate, non fidate.**
Ogni campo numerico ha limiti (ad esempio `fee_rate_sat_vb` fra 1 e
`MAX_FEE_RATE_SAT_VB`, `round_duration_seconds` almeno 30) e `fee_address`
deve essere un indirizzo PLM valido. `paused` non è modificabile da
`PUT /admin/config`.
- *Motivazione:* un fee rate a 0 produce transazioni che nessun nodo inoltra,
una durata a 0 fa scadere il round appena aperto, un `fee_address` estraneo
manda la commissione in uno script irrecuperabile (B-05).
*Codice:* `app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py::test_config_rejects_unusable_values`, `tests/unit/test_admin.py::test_pause_cannot_be_toggled_through_the_config_endpoint`
@@ -0,0 +1,27 @@
# ADR-10 — I parametri del round sono fotografati all'apertura
- **Stato:** Accettata — implementazione da completare (B-95)
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**I parametri del round sono fotografati all'apertura.**
Durata, pausa successiva, costo della scommessa (`bet_amount_sats`),
`fee_address` e `draw_confirmations` vengono copiati sulla riga del round
quando si apre; una modifica vale dal round successivo, mai per quello in
corso. La pausa fra due round è quella annunciata dal round appena chiuso.
Il fee rate resta letto dal vivo: riguarda la singola transazione, non le
regole del round.
- *Motivazione:* abbassare la durata a metà round lo chiudeva all'istante e
spostava il conto alla rovescia di tutti i client (B-61); cambiare costo o
`fee_address` a metà round creava poste diverse con la stessa probabilità
e deviava la commissione del round in corso (B-95).
- *Conseguenze:* il prelievo minimo ([ADR-20](ADR-20-prelievo-minimo-costo-corrente-scommessa.md)) segue il costo corrente della
configurazione, non quello del round in corso. Regole in
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#0--apertura) (B-95).
*Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` ·
*Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
*Stato:* da implementare (B-95) · *Oggi:* sono fotografati solo durata e
pausa; costo, `fee_address` e fee rate sono letti dal vivo ([LIM-16]).
@@ -0,0 +1,31 @@
# ADR-11 — Il server rifiuta di partire con segreti inadeguati
- **Stato:** Accettata — implementazione da completare (B-91)
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il server rifiuta di partire con segreti inadeguati**:
`JWT_SECRET` sotto i 32 caratteri o `XPRV_ENCRYPTION_KEY` vuota fermano
l'avvio. Un `ADMIN_TOKEN` vuoto invece non è fatale: il pannello resta
chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
`Settings`.
- *Motivazione:* evitare un container che sembra sano e si rompe al primo
utente reale; tenerlo fuori da `Settings` permette a import e test di
funzionare senza `.env`.
- *Alternative scartate:* validatore Pydantic su `Settings` (avrebbe fatto
fallire la raccolta dei test su un clone pulito, B-15).
- *Conseguenze:* lo stesso principio («rifiutare di servire piuttosto che
rompersi al primo uso») si estende alla master key decifrata e confrontata
con gli indirizzi già salvati (B-89) e al quorum minimo di server (B-84);
l'ordine dei controlli all'avvio è in
[architettura.md](../architettura.md#nel-lifespan).
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_config.py`
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
mancante o vuoto.
@@ -0,0 +1,23 @@
# ADR-12 — Il server gira solo in Docker, in sviluppo come in produzione
- **Stato:** Accettata — implementazione da completare (B-81)
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il server gira solo in Docker, in sviluppo come in
produzione**, dietro Caddy (TLS automatico, certificato autofirmato su
`localhost`). Il container rifiuta di partire senza il file della master
key, applica le migrazioni Alembic all'avvio e si riavvia da solo
(`restart: unless-stopped`). Il venv serve solo per test, migrazioni e
script della chiave.
- *Motivazione:* un solo modo di eseguire il server, identico ovunque.
- *Conseguenze:* dopo un crash ogni stato del round riprende da solo,
`drawing` compreso ([ADR-25](ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md), B-81). Procedure in
[operativita.md](../operativita.md).
*Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` ·
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
*Stato:* da implementare (B-81) · *Oggi:* dopo un crash `closing` e
`paying_out` riprendono da soli, `drawing` no ([LIM-01]).
+22
View File
@@ -0,0 +1,22 @@
# ADR-13 — Un solo processo, un solo file SQLite
- **Stato:** Accettata
- **Area:** Configurazione e ambiente
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Un solo processo, un solo file SQLite** (modalità WAL con
`busy_timeout`, `synchronous=FULL`, `BEGIN IMMEDIATE` per ogni transazione
che sposta denaro, vedi [architettura.md](../architettura.md#database), B-88).
Canale SSE, blocchi per-utente, limitatori di tentativi e cache sono in
memoria.
- *Motivazione:* semplicità a scala di ~100 utenti concorrenti.
- *Alternative scartate:* Redis o un database con lock condivisi, da non
introdurre in via speculativa.
- *Conseguenze:* nessuna distribuzione su più worker ([LIM-18]). L'unico
invariante che non dipende dal processo singolo è «al più un round
attivo», garantito da un indice ([ADR-22](ADR-22-piu-round-attivo-come-invariante-database.md)).
*Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/db/base.py:begin_immediate` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` ·
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
@@ -0,0 +1,17 @@
# ADR-14 — Ripartizione fissa 70% al vincitore / 30% alla piattaforma
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Ripartizione fissa 70% al vincitore / 30% alla piattaforma**,
con l'arrotondamento a favore delle commissioni. È nel codice, non nella
configurazione: cambiarla è una modifica del software.
- *Motivazione:* una regola economica di fondo non deve poter cambiare con un
clic dal pannello. Un'unica funzione alimenta sia il montepremi mostrato sia
il pagamento, così i due non possono divergere.
*Codice:* `app/rounds/service.py:winner_share` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`
@@ -0,0 +1,18 @@
# ADR-15 — Biglietto a prezzo fisso, una scommessa per utente per round, probabilità uguale per tutti
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Biglietto a prezzo fisso, una scommessa per utente per round,
probabilità uguale per tutti** indipendentemente dall'importo effettivo.
Nessun deposito minimo.
- *Conseguenze:* l'estrazione sceglie fra partecipanti, non pesa gli
importi. Il costo è fotografato all'apertura del round ([ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), così
tutti i partecipanti di un round pagano la stessa posta (B-95; oggi è letto
dal vivo, [LIM-16]).
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` ·
*Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round`
@@ -0,0 +1,18 @@
# ADR-16 — La commissione di rete si toglie dall'importo che si muove
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**La commissione di rete si toglie dall'importo che si
muove**, non si aggiunge sopra: per una scommessa arriva al montepremi il
costo meno la commissione, per un prelievo arriva a destinazione l'importo
meno la commissione.
- *Conseguenze:* il montepremi si calcola sommando gli importi effettivi
delle scommesse, non moltiplicando il numero di giocatori per il costo
(B-11); il prelievo registra sia l'importo richiesto sia quello inviato.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_deducts_fee_from_amount_not_change`
@@ -0,0 +1,35 @@
# ADR-17 — La commissione del pagamento esce dalla quota del vincitore; la riserva per l'RBF esce dalla quota della piattaforma
- **Stato:** Accettata — implementazione da completare (B-79)
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**La commissione del pagamento esce dalla quota del
vincitore; la riserva per l'RBF esce dalla quota della piattaforma.** Ogni
pagamento ha un'uscita di **riserva** di
`max(DUST_LIMIT_SATS, dimensione stimata × 10 sat/vB)` verso l'indirizzo di
riserva del montepremi ([ADR-03](ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md)); un'accelerazione riduce quell'uscita. La
commissione verso `fee_address` è quindi il 30% meno la riserva.
- *Motivazione:* il pagamento spende di norma tutte e sole le monete del
round, quindi non ha resto e non si potrebbe mai accelerare: un pagamento
fermo in mempool terrebbe il round in `paying_out` per sempre (B-79). La
riserva la paga la piattaforma perché è la piattaforma a scegliere il fee
rate.
- *Alternative scartate:* riserva presa dalla quota del vincitore (il
montepremi annunciato scenderebbe ancora); CPFP da un'uscita del vincitore
(richiede una seconda transazione e una moneta del vincitore); pagamento
senza riserva e abbandono dopo un timeout (vietato da [ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)).
- *Conseguenze:* il montepremi annunciato (il 70% delle scommesse
confermate) supera di poco quanto il vincitore riceve davvero; la
differenza, la commissione del pagamento, non è nota finché il pagamento non
è costruito. [ADR-29](ADR-29-senza-fee-address-apre-nessun-round.md) rifiuta di aprire round con una configurazione in cui
un round da un partecipante non coprirebbe commissione del vincitore,
riserva e una commissione sopra la soglia di polvere. Regole in
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento) (B-79).
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`
*Stato:* da implementare (B-79) · *Oggi:* nessuna riserva: il 30% va intero a
`fee_address` e, nel caso normale, il pagamento non ha resto.
@@ -0,0 +1,22 @@
# ADR-18 — L'accelerazione RBF la paga chi riceve il resto
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**L'accelerazione RBF la paga chi riceve il resto**: l'utente
per scommesse e prelievi, la piattaforma (tramite la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md)) per
i pagamenti. Le uscite verso la controparte non si toccano mai. La regola,
con i casi in cui il resto non basta, è di
[TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta).
- *Motivazione:* chi riceve il resto è chi ha scelto di spendere; ridurre
l'uscita del destinatario cambierebbe l'importo che la controparte si
aspetta (B-17).
- *Conseguenze:* ogni transazione deve avere un'uscita che il mittente può
ridurre: da qui [ADR-19](ADR-19-ogni-transazione-utente-conserva-uscita-resto.md) per le transazioni utente e la riserva di
[ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md) per i pagamenti ([LIM-02]).
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/broadcast.py:_find_change_output` ·
*Test:* `tests/unit/test_broadcast.py`
@@ -0,0 +1,37 @@
# ADR-19 — Ogni transazione utente conserva un'uscita di resto verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina
- **Stato:** Accettata — implementazione da completare (B-87)
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Ogni transazione utente conserva un'uscita di resto verso
l'utente, e un'accelerazione che porterebbe quel resto sotto
`DUST_LIMIT_SATS` (294 sat) lo elimina.** Le regole sono di
[BET-14](../scommesse-e-prelievi.md#cosa-succede) e
[WDR-09](../scommesse-e-prelievi.md#prelievo) (resto minimo alla costruzione)
e di [TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)
(resto eliminato all'accelerazione, B-87).
- *Motivazione:* un prelievo totale produceva una transazione a un'uscita
che nessun RBF poteva salvare (B-62); aggiungere monete non avrebbe
aiutato, perché la transazione le spendeva già tutte. Il resto minimo di
294 sat però **non basta a restare accelerabile**: il primo aumento
minimo (1 sat/vB sull'intera transazione, 141 sat per un ingresso e due
uscite) lo porta a 153 sat, sotto la soglia di polvere, e ogni sostituzione
successiva viene rifiutata dalle regole di inoltro (B-87). Garantisce
l'esistenza dell'uscita, non la capacità di assorbire aumenti.
- *Alternative scartate:* un resto minimo più grande (sottrae all'utente un
importo che deve comunque poter prelevare, e sposta solo la soglia);
aggiungere ingressi alla sostituzione (nel caso che conta, il prelievo
totale, non ce ne sono altri).
- *Conseguenze:* quando il resto scende sotto soglia, il suo valore va in
commissione e la sostituzione è l'ultima possibile per quella transazione;
l'utente perde al più 294 sat. **Il pagamento del round** non ha un resto
utente: la sua uscita riducibile è la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
*Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il
resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo
inoltra.
@@ -0,0 +1,16 @@
# ADR-20 — Il prelievo minimo è il costo corrente della scommessa
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il prelievo minimo è il costo corrente della scommessa**, senza
un campo di configurazione separato.
- *Motivazione:* un parametro in meno da tenere coerente.
- *Conseguenze:* alzare il costo della scommessa alza anche il prelievo
minimo, con effetto immediato.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_amount_below_minimum`
@@ -0,0 +1,33 @@
# ADR-21 — Tre limiti distinti sul numero di monete e di giocatori:
- **Stato:** Accettata
- **Area:** Economia del gioco
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Tre limiti distinti sul numero di monete e di giocatori:**
| Costante | Valore | Protegge da |
|---|---|---|
| `MAX_TX_INPUTS` | 50 | una transazione utente (scommessa, prelievo) che erode l'importo in commissioni per un indirizzo troppo frammentato; oltre, `too_many_inputs` |
| `MAX_PAYOUT_TX_INPUTS` | 500 | un pagamento troppo grande per le regole di inoltro (~1470 ingressi a 100 kvB); un terzo del margine |
| `MAX_PARTICIPANTS_PER_ROUND` | 400 | un round impagabile: il 401° giocatore è rifiutato con `round_full` **prima** che si muova denaro |
- *Motivazione:* il pagamento spende una moneta del montepremi per scommessa;
usare per esso il limite utente rendeva impagabile ogni round oltre ~50
giocatori e fermava la lotteria (B-52). Con un indirizzo per round
([ADR-03](ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md), B-80) il pagamento spende solo le monete del round, quindi i 100
di differenza fra 400 e 500 restano un margine fisso invece di consumarsi
con le monete avanzate dai round precedenti (come accade oggi con
l'indirizzo unico).
- *Conseguenze:* «ogni round si può pagare» è un invariante verificato
all'ingresso, non una scoperta al momento del pagamento. Il conteggio dei
partecipanti include quelli non ancora confermati (direzione prudente). Un
tetto opzionale al montepremi del round (`max_round_pool_sats`, 0 =
disattivato) rifiuta con `round_full` anche le scommesse oltre quel valore
(B-95); se e come legarlo al costo di un attacco al 51% è una decisione
non ancora presa ([LIM-24]).
*Codice:* `app/wallet/psbt_builder.py:select_utxos` · `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`, `tests/unit/test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`, `tests/unit/test_payout_builder.py::test_payout_at_the_participant_cap_stays_well_inside_relay_limits`
@@ -0,0 +1,19 @@
# ADR-22 — Al più un round attivo, come invariante del database
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Al più un round attivo, come invariante del database.** Un
indice unico su un'espressione costante, limitato agli stati attivi, fa
fallire in modo pulito il secondo inserimento concorrente; chi perde adotta
il round del vincitore (al massimo 3 tentativi). I round non si
sovrappongono mai: il successivo si apre solo quando il precedente è
`closed`.
- *Alternative scartate:* rendere lo scheduler l'unico autore dei round (la
prima scommessa dopo una pausa non avrebbe potuto aprirne uno, B-09).
*Codice:* `app/db/models.py:Round` · `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:get_active_round` ·
*Test:* `tests/unit/test_rounds_service.py::test_the_database_refuses_a_second_active_round`, `tests/unit/test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round`
@@ -0,0 +1,22 @@
# ADR-23 — Il limite per scommettere è la scadenza del round stesso, non il suo stato nel database
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il limite per scommettere è la scadenza del round stesso, non
il suo stato nel database**, e la chiusura «a semaforo giallo» aspetta che
ogni scommessa già inviata sia confermata prima di estrarre. L'iscrizione è
salvata con un compare-and-set sulla riga del round.
- *Motivazione:* lo scheduler controlla ogni 5s, quindi lo stato può restare
`open` oltre la scadenza; senza il compare-and-set una scommessa poteva
finire nel montepremi ed essere esclusa dall'estrazione (B-53).
- *Conseguenze:* l'attesa in `closing` finisce solo quando ogni scommessa è
confermata o dimostrata morta ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)); non ha un limite di tempo
([LIM-03]). Dettagli in
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#2--chiusura).
*Codice:* `app/rounds/service.py:round_accepts_bets` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `tests/unit/test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check`
@@ -0,0 +1,58 @@
# ADR-24 — L'estrazione è un commit-reveal fra il server e un blocco futuro
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**L'estrazione è un commit-reveal fra il server e un blocco
futuro** (decisione del 2026-09-28). All'apertura del round il server genera
un segreto casuale di 32 byte (`server_seed`), lo salva e pubblica solo la
sua impronta `server_seed_hash = sha256(server_seed)`. All'ingresso in
`drawing` si fissano l'elenco dei partecipanti confermati, ordinato per txid
della scommessa crescente, la sua impronta `participants_hash` (sha256 delle
righe `txid:vout:indirizzo`) e l'altezza del blocco di estrazione
(`draw_height`, [ADR-25](ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)). Il seme è
`sha256(server_seed || block_hash || participants_hash)` letto come intero,
l'indice del vincitore è seme modulo numero di partecipanti. Dopo il
salvataggio del vincitore il server rivela `server_seed` e pubblica la prova
completa (`GET /rounds/{id}/proof`), da cui chiunque ricalcola l'esito.
L'algoritmo resta un **componente sostituibile**: il resto del sistema non
va architettato attorno alla sua forma, ma ogni sostituto deve mantenere le
stesse quattro proprietà — impegno pubblicato prima delle scommesse, elenco
dei partecipanti fissato prima del blocco, esito ricalcolabile dai dati
pubblicati, esito non calcolabile da chi produce o trasmette i blocchi.
- *Motivazione:* il vincitore non deve essere calcolabile da chi controlla
la produzione dei blocchi: la potenza di calcolo di PLM (~22 TH/s) si può
affittare. Un minatore che non conosce `server_seed` non sa chi vincerebbe
con il suo blocco e quindi non ha motivo di scartarlo; l'operatore non
può cambiare `server_seed` dopo averne pubblicato l'impronta e non conosce
il blocco quando si impegna. L'ordine per txid e l'impronta dell'elenco
rendono l'esito riproducibile e impediscono di aggiungere o togliere
partecipanti dopo aver visto il blocco (B-73).
- *Alternative scartate:*
- *hash del solo blocco* (la forma precedente): un minatore con biglietti
in gioco trattiene i blocchi finché uno lo fa vincere, e il server
Electrum attivo sceglie quale blocco reale usare semplicemente non
annunciandone uno;
- *hash di più blocchi consecutivi*: chi produce l'ultimo blocco della
serie può ancora scartarlo e riprovare, quindi resta manipolabile;
- *segreti forniti dai giocatori* (commit-reveal fra partecipanti): chi non
rivela blocca o condiziona l'estrazione, e servirebbe un'azione in più a
ogni giocatore;
- *sorgente di casualità esterna* (beacon): una dipendenza di rete in più,
di cui fidarsi quanto del server.
- *Conseguenze:* l'operatore, che conosce `server_seed`, può calcolare
l'esito appena il blocco è noto: una collusione fra operatore e minatore
resta possibile ed è accettata ([LIM-25]), perché l'operatore è già
fidato come custode ([LIM-26]). `server_seed` non è esposto da nessun
endpoint prima della rivelazione, e nel database è cifrato con la chiave
della master xprv finché non viene rivelato
([sicurezza.md](../sicurezza.md#sec-56) SEC-56): una copia del database da
sola non basta a conoscerlo. Regole in
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#3--estrazione),
endpoint in [api.md](../api.md) (B-73).
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._complete_draw` · `app/rounds/service.py:open_new_round_if_needed` · `app/api/routes/rounds.py:round_proof` ·
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_draw_proof.py`
@@ -0,0 +1,37 @@
# ADR-25 — Il blocco di estrazione è fissato per altezza, è successivo alla chiusura, e si usa solo quando è confermato e corroborato
- **Stato:** Accettata — implementazione da completare (B-81)
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il blocco di estrazione è fissato per altezza, è successivo
alla chiusura, e si usa solo quando è confermato e corroborato.**
All'ingresso in `drawing` si salva `draw_height = tip_at_close + 1`, che non
cambia più. Si attende che quel blocco abbia `draw_confirmations` conferme
(3 per default, fotografato all'apertura, [ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), poi si chiede a tutti i
server l'header *a quell'altezza*: serve l'accordo del quorum ([ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)); in
caso di disaccordo si attende e si richiede la stessa altezza, mai un blocco
diverso. L'attesa non ha timeout (l'entropia dipende davvero da un blocco
futuro) ma è osservabile (`draw_stalled`, `draw_waiting_since`) e
riprendibile: `draw_height`, `participants_hash` e la posizione di ogni
partecipante sono salvati, e lo scheduler riprende un round in `drawing`
dopo un errore o un riavvio con lo stesso esito. Un'altezza `0` alla
chiusura significa «sconosciuta»: la prima altezza appresa fa da base.
- *Motivazione:* un hash già pubblico mentre le scommesse erano aperte
renderebbe il vincitore prevedibile (B-63); un header fabbricato
sceglierebbe il vincitore (B-28); un'altezza non fissata lascia al server
attivo la scelta di quale blocco reale usare, e passare a «un blocco
ulteriore» dopo una corroborazione fallita gli dà un secondo tentativo
(B-73). Le conferme sul blocco di estrazione evitano che una
riorganizzazione cambi il seme dopo l'estrazione. Uno stato tenuto solo in
memoria bloccava il round in `drawing` a ogni eccezione (B-81).
- *Alternative scartate:* timeout sull'attesa con ripiego su un altro blocco
(riaprirebbe la scelta del blocco).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* `tests/unit/test_scheduler.py::test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw`, `tests/unit/test_scheduler.py::test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash`
*Stato:* da implementare (B-81) · *Oggi:* `draw_height`, `participants_hash` e le
posizioni sono salvati, ma `_tick` salta i round in `drawing`: dopo un errore
o un riavvio il round non riparte.
@@ -0,0 +1,35 @@
# ADR-26 — Validazione degli header concatenata, senza ricalcolo della difficoltà
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Validazione degli header concatenata, senza ricalcolo della
difficoltà.** Un header deve dichiarare un bersaglio non più facile del
limite di rete (`powLimit`, `0x00000000ffff…`), rispettare quel bersaglio e
concatenarsi alla catena che teniamo: su un avanzamento di più blocchi si
scaricano gli header intermedi e si verifica tutta la catena. Il listener
tiene gli ultimi 10 header; un header che non si concatena alla punta ma a
uno di essi è una riorganizzazione da seguire ([ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)), non un server
ostile. Un header senza `hex` è ignorato senza chiudere la sessione. Regole
in [connessione-electrum.md](../connessione-electrum.md#4-header-validati-non-creduti)
(B-74, B-85).
- *Motivazione:* il controllo «rispetta il bersaglio che dichiara», da solo,
**non è una difesa**: il bersaglio lo sceglie chi produce l'header, e con
`bits=0x207fffff` un header passa senza lavoro. Un salto di più blocchi non
collegato alla punta permetteva di fissare un'altezza enorme, che non
regredisce mai, e congelare ogni estrazione (B-74). Trattare ogni
riorganizzazione di un blocco come un server ostile chiudeva la sessione
su tutti i server fino al blocco successivo.
- *Alternative scartate:* validazione completa della catena con le regole di
ricalcolo della difficoltà di PLM (fuori scopo: servirebbe la storia delle
difficoltà, e il commit-reveal di [ADR-24](ADR-24-l-estrazione-commit-reveal-fra-server.md) toglie a un header fabbricato il
potere di scegliere il vincitore).
- *Conseguenze:* un header a difficoltà minima costa ancora poco lavoro
([LIM-14]); a fermare una catena fabbricata sono la concatenazione, il
quorum ([ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)) e il fatto che il seme dipende anche da `server_seed`.
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` ·
*Test:* `tests/unit/test_electrum_listener.py::test_a_multi_block_jump_is_validated_by_fetching_the_headers_in_between`, `tests/unit/test_electrum_listener.py::test_a_fork_is_walked_back_and_followed_instead_of_ending_the_session`, `tests/unit/test_electrum_listener.py::test_a_competing_header_at_the_tip_height_is_a_reorg_to_follow`, `tests/unit/test_draw.py::test_a_header_below_the_pow_limit_is_rejected_even_if_it_meets_its_own_target`
@@ -0,0 +1,19 @@
# ADR-27 — Pausa di cortesia fra un round e l'altro
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Pausa di cortesia fra un round e l'altro**
(`round_cooldown_seconds`, 30s), contata dalla **conferma del pagamento**, non
dall'inizio della chiusura, così i giocatori vedono l'esito. Le regole sono di
[RND-04](../ciclo-di-vita-del-round.md#0--apertura) (quando si apre il round
successivo) e [RND-28](../ciclo-di-vita-del-round.md#4--pagamento) (da quando
si conta).
- *Motivazione:* non è nel diagramma originale; senza pausa il round
successivo si apriva prima che i client avessero mostrato il risultato.
*Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_during_cooldown`
@@ -0,0 +1,24 @@
# ADR-28 — La pausa per manutenzione ferma solo il round successivo
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**La pausa per manutenzione ferma solo il round successivo.**
È un'azione esplicita dell'operatore con endpoint propri e registrati in
audit (`lottery_paused`, `lottery_resumed`), non un campo di configurazione;
il round in corso chiude, estrae e paga come qualunque altro round.
- *Motivazione:* sospendere a metà un round con denaro nel montepremi
creerebbe proprio lo stato bloccato che la pausa vuole evitare.
- *Conseguenze:* la stessa pausa è anche il modo in cui il sistema chiede
l'intervento dell'operatore, con un motivo (`paused_reason`): una
riorganizzazione profonda ([ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-85), un pagamento il cui vincitore
non ha più la scommessa in catena (B-82), una transazione abbandonata che
conferma in ritardo ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md), B-78), un avvio con `START_PAUSED=true` dopo
un ripristino (B-90). Solo l'operatore la toglie. Anche in questi casi il
round in corso prosegue, salvo il pagamento trattenuto di B-82.
*Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_paused`, `tests/unit/test_rounds_service.py::test_pause_does_not_interrupt_a_round_in_progress`, `tests/unit/test_admin.py::test_admin_can_pause_and_resume_the_lottery`
@@ -0,0 +1,24 @@
# ADR-29 — Senza `fee_address` non si apre nessun round
- **Stato:** Accettata — implementazione da completare (B-79)
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Senza `fee_address` non si apre nessun round.** Il campo non ha
valore predefinito: l'operatore deve impostare il proprio. Finché manca, nessun
round si apre (`lottery_configured = false` su `GET /rounds/current`, avviso
in `/admin`); un round già in corso invece chiude, estrae e paga anche se
l'indirizzo viene cancellato. Qualunque altra condizione che renderebbe un
round impagabile va aggiunta nello stesso punto.
- *Motivazione:* un round aperto senza `fee_address` raccoglieva scommesse e
solo al pagamento scopriva di non poter essere pagato (B-66).
- *Conseguenze:* nello stesso punto si aggiunge il controllo che un round
con un solo partecipante copra commissione del vincitore, riserva per
l'RBF e una commissione sopra la soglia di polvere ([ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79).
*Codice:* `app/rounds/service.py:rounds_can_open` · `app/rounds/config.py:get_round_config` ·
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address`
*Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo
`fee_address`.
@@ -0,0 +1,20 @@
# ADR-30 — Il montepremi annunciato è quello che sarà pagato
- **Stato:** Accettata
- **Area:** Round ed estrazione
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il montepremi annunciato è quello che sarà pagato**: il 70%
delle sole scommesse confermate, affiancato dai valori «in attesa» che
includono le scommesse in volo (valori inclusivi, non differenze).
- *Motivazione:* mai annunciare più di quanto l'estrazione e il pagamento
considereranno (B-65), senza nascondere al giocatore la propria scommessa
appena fatta.
- *Conseguenze:* se prima del pagamento una scommessa confermata risulta
sparita per una riorganizzazione, il partecipante esce dal calcolo
(`reorged_out`) e il pagato scende sotto l'annunciato ([ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82).
*Codice:* `app/api/routes/rounds.py:current_round` ·
*Test:* `tests/unit/test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid`
@@ -0,0 +1,23 @@
# ADR-31 — Tutto ciò che spende denaro si scrive prima di inviarlo
- **Stato:** Accettata
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Tutto ciò che spende denaro si scrive prima di inviarlo** (stato
`building`, monete già segnate come spese) e si risolve contro la catena
dopo; nessuna sessione del database resta aperta durante una chiamata di
rete. Vale anche per le sostituzioni RBF: la nuova versione si salva come
candidata prima dell'invio (B-77). Solo un **rifiuto esplicito** della rete
annulla tutto e risponde **502**; un invio dall'esito incerto (timeout,
connessione caduta dopo la scrittura) lascia la riga `building` al
riconciliatore e risponde **202** ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)).
- *Motivazione:* un crash fra invio e salvataggio lasciava monete spese in
catena senza traccia (B-08, B-25). Dettagli in
[transazioni-e-riconciliazione.md](../transazioni-e-riconciliazione.md).
*Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py`
@@ -0,0 +1,15 @@
# ADR-32 — Il pagamento si ritenta da solo, mai due volte
- **Stato:** Accettata
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il pagamento si ritenta da solo, mai due volte**: un tentativo
ogni 60s, usando l'ultimo `payout_failed` in audit come orologio; prima di
costruire si rifiuta se esiste già un pagamento `building`/`pending` per il
round, e si escludono le monete impegnate da pagamenti non risolti.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
*Test:* `tests/unit/test_scheduler.py::test_trigger_payout_skips_when_already_in_flight`, `tests/unit/test_scheduler.py::test_tick_throttles_retry_after_a_recent_payout_failure`, `tests/unit/test_scheduler.py::test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout`
@@ -0,0 +1,17 @@
# ADR-33 — Scommessa e prelievo si escludono solo tramite il blocco per-utente
- **Stato:** Accettata
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Scommessa e prelievo si escludono solo tramite il blocco
per-utente** sulla *costruzione*: un prelievo è accettato anche mentre una
scommessa attende conferma, purché le monete confermate e libere lo coprano.
- *Motivazione:* il rischio è che scelgano la stessa moneta, già escluso dal
blocco e dal fatto che la selezione salta le monete segnate come spese;
congelare il resto del saldo non aggiungerebbe sicurezza (B-70).
*Codice:* `app/tx/locks.py:UserLocks` · `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`
@@ -0,0 +1,17 @@
# ADR-34 — Saldo spendibile e saldo mostrato sono due numeri distinti
- **Stato:** Accettata
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Saldo spendibile e saldo mostrato sono due numeri distinti.**
Si spende solo il confermato (`balance_sats`); si mostra quello che include i
resti ancora in volo (`pending_balance_sats`, in ambra finché non conferma).
Un prelievo coperto solo dal secondo riceve `balance_pending_confirmation`.
- *Motivazione:* la selezione a monete intere sottrae l'intera moneta al
saldo confermato già all'invio, molto più di quanto si stia spostando.
*Codice:* `app/wallet/balance.py:compute_pending_balance` · `app/wallet/balance.py:recompute_balance` · `app/api/routes/users.py:me` ·
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance`
@@ -0,0 +1,14 @@
# ADR-35 — Aritmetica solo in satoshi interi
- **Stato:** Accettata
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Aritmetica solo in satoshi interi**, anche per i valori letti
dalla rete: l'importo di una moneta spesa si ricava dalla transazione grezza,
non dal valore decimale restituito dal server.
*Codice:* `app/tx/broadcast.py:_prevout_amount` · `app/wallet/psbt_builder.py:estimate_vsize` ·
*Test:* `tests/unit/test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls`
@@ -0,0 +1,19 @@
# ADR-36 — Password con Argon2, sessioni con JWT HS256 da 24 ore, revocabili
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Password con Argon2, sessioni con JWT HS256 da 24 ore,
revocabili.** Il token porta la versione dell'account (`tv`); cambio password
e reset admin la incrementano e invalidano tutte le sessioni precedenti. Un
token senza `tv` vale come versione 0, così il rilascio non ha disconnesso
nessuno. Argon2 gira fuori dall'event loop.
- *Motivazione:* un reset per account compromesso deve buttare fuori subito
l'attaccante (B-34); Argon2 inline bloccava l'intero processo, attività in
background comprese (B-55).
*Codice:* `app/auth/security.py:create_access_token` · `app/auth/security.py:decode_access_token` · `app/auth/dependencies.py:get_current_user` · `app/auth/security.py:verify_password_async` ·
*Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one`
@@ -0,0 +1,16 @@
# ADR-37 — Nessun recupero password, solo reset
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Nessun recupero password, solo reset.** Non si raccoglie
nessuna email. L'utente autenticato può *cambiare* la password fornendo
quella attuale; chi l'ha persa si rivolge all'operatore, che con
`POST /admin/users/{id}/reset-password` ne genera una casuale, mostrata una
volta sola e registrata in audit (`admin_password_reset`).
*Codice:* `app/api/routes/users.py:change_password` · `app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_users.py::test_change_password_requires_current_password`, `tests/unit/test_admin.py::test_admin_resets_user_password`
@@ -0,0 +1,20 @@
# ADR-38 — Gli username sono un unico spazio di nomi senza distinzione fra maiuscole e minuscole
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Gli username sono un unico spazio di nomi senza distinzione fra
maiuscole e minuscole**, garantito da un indice unico su `lower(username)`;
il nome resta salvato come digitato (è ciò che mostrano `/admin` e l'audit).
Sono ammessi solo caratteri ASCII `[A-Za-z0-9_.-]`, 3–32. La migrazione si
rifiuta di partire se due account esistenti differiscono solo per le
maiuscole, invece di sceglierne uno da rinominare: entrambi potrebbero avere
fondi.
- *Motivazione:* «Bob» e «bob» erano due account che condividevano il
contatore dei tentativi e un comodo vettore di impersonazione (B-57).
*Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · `app/auth/routes.py:login` ·
*Test:* `tests/unit/test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `tests/unit/test_auth.py::test_the_database_itself_rejects_a_case_variant`
@@ -0,0 +1,16 @@
# ADR-39 — L'esportazione della chiave privata WIF di un utente è voluta, non una vulnerabilità
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**L'esportazione della chiave privata WIF di un utente è
voluta, non una vulnerabilità.** Il server possiede già la master key:
l'endpoint espone via API solo ciò che un operatore potrebbe comunque
ottenere con uno script (ad esempio per recuperare fondi bloccati). Ogni
accesso scrive `admin_privkey_accessed`, e quella registrazione non va tolta.
*Codice:* `app/api/routes/admin.py:user_privkey` · `app/wallet/hd.py:derive_user_wif` ·
*Test:* `tests/unit/test_admin.py::test_admin_exports_user_privkey`
@@ -0,0 +1,18 @@
# ADR-40 — L'amministrazione usa un unico token condiviso
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**L'amministrazione usa un unico token condiviso**
(`X-Admin-Token` confrontato a tempo costante sui byte UTF-8 con
`ADMIN_TOKEN`), non un login per operatore. `/admin` mostra una schermata di
inserimento del token, **non è collegato da `/`** (né `/` da `/admin`) ed è in
italiano, non tradotto.
- *Conseguenze:* l'audit registra *cosa* è cambiato, mai *chi* l'ha fatto
([LIM-06]).
*Codice:* `app/api/routes/admin.py:require_admin` · `app/main.py:admin_panel` · `app/static/admin.html` ·
*Test:* `tests/unit/test_admin.py::test_admin_requires_token`, `tests/unit/test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500`
@@ -0,0 +1,28 @@
# ADR-41 — Limitazione dei tentativi solo su login e registrazione
- **Stato:** Accettata — implementazione da completare (B-94)
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Limitazione dei tentativi solo su login e registrazione**, in
memoria. Il login usa un ritardo esponenziale per username (dopo 5 errori) e
per IP (dopo 20, soglia più alta per non punire una rete condivisa); un
login riuscito azzera solo il contatore dello username. La registrazione usa
invece una **quota** di 5 account per IP all'ora, consumata solo dagli
account creati davvero. I contatori sono limitati in numero.
- *Motivazione:* indovinare una password e creare account in massa sono due
problemi diversi (B-33, B-58); il ritardo punisce i tentativi, la quota
limita la produzione.
- *Conseguenze:* nessun limite su scommesse, prelievi, admin ([LIM-07]).
Il cambio password (limitato e registrato come `password_changed`) e le
segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo
delle credenziali ha una lunghezza massima di 256 caratteri
([sicurezza.md](../sicurezza.md#limiti-di-frequenza), B-94).
*Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
*Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di
bug non hanno limiti né lunghezze massime, e il cambio password non scrive
audit.
@@ -0,0 +1,16 @@
# ADR-42 — L'IP del client è l'ultimo elemento di `X-Forwarded-For`
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**L'IP del client è l'ultimo elemento di `X-Forwarded-For`**, e
Caddy sovrascrive l'header con il vero indirizzo del peer. Un solo proxy
fidato sta davanti all'applicazione, che non è esposta direttamente.
- *Motivazione:* il primo elemento è scelto dal client; leggerlo rendeva
aggirabile ogni controllo per IP (B-54). Basta una delle due misure.
*Codice:* `app/api/client_ip.py:client_ip` · `Caddyfile` ·
*Test:* `tests/unit/test_client_ip.py`, `tests/unit/test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer`
@@ -0,0 +1,18 @@
# ADR-43 — Header di sicurezza nel proxy, con `'unsafe-inline'` accettato
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Header di sicurezza nel proxy, con `'unsafe-inline'`
accettato**: HSTS, `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy` e una
CSP `default-src 'self'` più Google Fonts. Script e stili inline restano
permessi perché entrambe le SPA usano `onclick` e `style=""`; rimuoverli è un
refactoring a parte. La documentazione automatica dell'API (`/docs`,
`/redoc`, `/openapi.json`) è spenta per default (`ENABLE_API_DOCS`).
- *Conseguenze:* [LIM-09].
*Codice:* `Caddyfile` · `app/main.py` · `app/config.py:Settings` ·
*Test:* `tests/unit/test_caddyfile_security_headers.py`, `tests/unit/test_docs_disabled.py`
@@ -0,0 +1,15 @@
# ADR-44 — `/qr/{address}` è pubblico ma limitato
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**`/qr/{address}` è pubblico ma limitato**: nessuna
autenticazione (lo usa un `<img>`, che non può portare il token), ma
l'indirizzo è validato davvero, la lunghezza è limitata, la resa è in cache
(512 voci) e fuori dall'event loop.
*Codice:* `app/api/routes/qr.py:address_qr` ·
*Test:* `tests/unit/test_qr.py`
@@ -0,0 +1,16 @@
# ADR-45 — Segnalazioni di bug aperte a tutti, triage in admin
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Segnalazioni di bug aperte a tutti, triage in admin.** Si può
inviare una segnalazione da autenticati (viene attribuita all'utente) o no;
il testo va scritto in inglese, e la pagina lo dice nella lingua del
visitatore. Gli stati sono `open` → `read` → `resolved`, e ogni cambio reale
di stato è registrato in audit.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report` · `app/api/routes/admin.py:update_bug_report_status` ·
*Test:* `tests/unit/test_bug_reports.py`
@@ -0,0 +1,23 @@
# ADR-46 — Il canale SSE è un'aggiunta al polling, non un sostituto
- **Stato:** Accettata
- **Area:** Interfacce, API e lingue
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il canale SSE è un'aggiunta al polling, non un sostituto.**
Nessun contenuto e nessuna autenticazione: è solo un «è cambiato qualcosa,
ricarica»; la personalizzazione resta negli endpoint REST autenticati. Un
unico segnale per tutti (non per utente), una coda da 1 elemento per client
così le notifiche ridondanti si fondono, tetto di 500 connessioni globali e 5
per IP (oltre, si chiude la connessione più vecchia dello stesso IP). Le
pagine ricaricano anche all'evento `open` di ogni riconnessione.
- *Alternative scartate:* canale per utente (servirebbe autenticazione sul
flusso e sapere a chi interessa ogni evento); Redis pub/sub (solo quando ci
saranno più processi).
- *Conseguenze:* se il canale cade o è bloccato, le pagine continuano a
funzionare con il solo polling.
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` · `app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
@@ -0,0 +1,19 @@
# ADR-47 — L'API parla una sola lingua e risponde con codici
- **Stato:** Accettata
- **Area:** Interfacce, API e lingue
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**L'API parla una sola lingua e risponde con codici.** Ogni
errore rivolto all'utente ha `detail = {code, message, params}`: `message` è
in inglese per chi non usa la dashboard, `code` è ciò che il frontend traduce
(`error.<code>`), `params` porta i valori (gli importi come `*_sats`). Anche
l'errore 500 generico ha questa forma (`internal_error`) e non espone il
testo dell'eccezione.
- *Conseguenze:* aggiungere un errore significa dargli un codice e
aggiungere `error.<code>` in tutte le 7 lingue (vedi [api.md](../api.md)).
*Codice:* `app/api/errors.py:ApiError` · `app/api/errors.py:http_error` · `app/main.py:log_unhandled_exception` ·
*Test:* `tests/unit/test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape`
@@ -0,0 +1,20 @@
# ADR-48 — Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue
- **Stato:** Accettata
- **Area:** Interfacce, API e lingue
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue**
(en, it, es, fr, de, ru, zh) da un'unica tabella piatta, senza build né
download. Lingua scelta da `localStorage.plm_lang`, poi dal browser, poi
inglese; nessun'altra lingua di riserva oltre l'inglese. Il selettore di `/`
sta nella barra di stato della catena, non nella barra di navigazione
(nascosta prima del login). Un elemento è tradotto *o* dagli attributi
`data-i18n` *o* dal codice con `t()`, mai da entrambi. Tutte le lingue devono
avere esattamente le stesse chiavi.
- *Conseguenze:* la parità delle chiavi non è verificata da un test
([LIM-21]).
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:applyStaticTranslations` · `app/static/i18n.js:setLanguage` · `app/static/app.js:onLanguageChange`
@@ -0,0 +1,12 @@
# ADR-49 — Il pannello `/admin` e la pagina `/guida` non sono tradotti
- **Stato:** Accettata
- **Area:** Interfacce, API e lingue
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Il pannello `/admin` e la pagina `/guida` non sono tradotti**
(sono in italiano, per l'operatore).
*Codice:* `app/static/admin.html` · `app/static/guida.html`
@@ -0,0 +1,20 @@
# ADR-50 — Frontend statici, senza framework né build
- **Stato:** Accettata — implementazione da completare (B-94)
- **Area:** Interfacce, API e lingue
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Frontend statici, senza framework né build**: due SPA servite
direttamente da FastAPI, che parlano con la stessa API JSON (nessuna
separazione fra API utente e admin oltre al controllo del token). Le pagine
HTML hanno `Cache-Control: no-store`, i file JS e CSS `Cache-Control:
no-cache` (il browser li riconvalida a ogni caricamento, così un
aggiornamento non lascia una pagina nuova con uno script vecchio;
[architettura.md](../architettura.md#allimport), B-94).
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js`
*Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno
`Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne
hanno.
@@ -0,0 +1,39 @@
# ADR-51 — Nel dubbio non si liberano mai i fondi
- **Stato:** Accettata
- **Area:** Denaro in movimento
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Nel dubbio non si liberano mai i fondi** (decisione del
2026-09-28). Una riga che sposta denaro (scommessa, prelievo, pagamento) si
annulla, liberandone le monete, solo con una **prova**: un rifiuto esplicito
della rete all'invio, oppure i suoi ingressi spesi da un'altra transazione
confermata (corroborata dal quorum), oppure nessun server del quorum che
conosca alcun txid della riga *e* una ritrasmissione dell'ultima versione a
tutti i server rifiutata per ingressi mancanti o già spesi. Mai per un
timeout, per l'età della riga o per il silenzio di un solo server. Si
seguono tutti i txid che una riga ha avuto (ogni versione RBF): la conferma
di uno qualsiasi conferma la riga. Le righe `failed` restano sorvegliate per
14 giorni; una conferma tardiva scrive `pending_tx_resurrected` e mette in
pausa la lotteria ([ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md)). Regole in
[transazioni-e-riconciliazione.md](../transazioni-e-riconciliazione.md#riconciliazione)
(B-76, B-77, B-78).
- *Motivazione:* liberare le monete di una transazione che poi conferma è
l'errore peggiore possibile: la scommessa finisce nel montepremi senza
partecipante, il prelievo parte mentre la riga dice `failed`, il pagamento
viene ricostruito e il vincitore pagato due volte. Tutti e tre accadevano
con un invio andato in timeout (B-76), con la conferma di una versione RBF
precedente (B-77) o con un riavvio su un server che non aveva ancora visto
la transazione (B-78).
- *Alternative scartate:* abbandono a tempo (la regola precedente: 120s per
`building`, 6 ore per `pending`), che trasforma un ritardo di rete in una
perdita; abbandono sul parere del solo server attivo.
- *Conseguenze:* una transazione ferma ma viva non si abbandona mai: la si
accelera ([ADR-18](ADR-18-l-accelerazione-rbf-paga-chi-riceve.md)); un round può restare in `closing` o `paying_out`
finché la rete non decide ([LIM-02], [LIM-03]). Il denaro in dubbio resta
congelato e visibile, mai duplicato.
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_is_due` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:bump_fee` ·
*Test:* `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_tx_versions.py`, `tests/unit/test_reconcile_proof.py`
@@ -0,0 +1,48 @@
# ADR-52 — Le riorganizzazioni della catena si contengono invece di ignorarle
- **Stato:** Accettata — implementazione da completare (B-82)
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Le riorganizzazioni della catena si contengono invece di
ignorarle** (decisione del 2026-09-28). Tre misure:
- *si rilevano*: il listener tiene gli ultimi 10 header e segue una
biforcazione fino al punto comune; ogni riorganizzazione scrive
`chain_reorg_detected` con profondità e altezze. Una biforcazione che non
trova il punto comune entro 10 header è trattata come un header non
valido (fine della sessione e rotazione, [ADR-26](ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md));
- *si fermano quelle profonde*: una profondità ≥ 2 mette in pausa la
lotteria (`paused_reason = 'reorg'`, [ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md)) finché l'operatore non la
riprende;
- *si paga solo ciò che è in catena*: prima di costruire un pagamento le
monete dell'indirizzo del round si verificano con il quorum; i
partecipanti la cui scommessa non c'è più diventano `reorged_out` e la
ripartizione 70/30 si calcola su ciò che resta. Se manca la scommessa del
vincitore, il pagamento è trattenuto (`payout_blocked`) e la lotteria va in
pausa.
Regole in [connessione-electrum.md](../connessione-electrum.md#4-header-validati-non-creduti)
e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
(B-82, B-85).
- *Motivazione:* la regola precedente («1 conferma, nessun rollback»)
presupponeva riorganizzazioni rare e innocue. Con la potenza di PLM in
affitto una riorganizzazione è un attacco possibile, e senza queste misure
una sola scommessa sparita rende il round impagabile per sempre (B-82),
mentre una riorganizzazione di un blocco, normale in qualunque catena,
veniva scambiata per un server ostile (B-74).
- *Alternative scartate:* ignorare le riorganizzazioni (la regola
precedente); un rollback completo (ricalcolare saldi già spesi e ripetere
estrazioni già rivelate non è sempre possibile e sposterebbe denaro senza
l'operatore); pausa anche per profondità 1 (una riorganizzazione di un
blocco è frequente e non tocca i depositi a 6 conferme, [ADR-06](ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)).
- *Conseguenze:* un attacco al 51% resta possibile; queste misure lo
rendono visibile, fanno sì che non blocchi la lotteria in modo
irrecuperabile e limitano il danno a ciò che l'attaccante ha rimosso
([LIM-24]).
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento non verifica le monete
del round: le prime due misure (rilevare, fermare) sono attive.
@@ -0,0 +1,24 @@
# ADR-53 — Il registro di audit è in sola aggiunta e si scrive nella transazione che descrive
- **Stato:** Accettata
- **Area:** Account, amministrazione e sicurezza
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
## Decisione
**Tutto ciò che muove denaro o cambia la configurazione lascia una riga in
`audit_log`; la riga nasce da un'unica funzione, è salvata nella stessa
transazione del cambiamento che descrive (tranne gli eventi puramente
osservativi) e non viene mai modificata né cancellata.**
- *Motivazione:* l'amministrazione ha un solo token condiviso senza identità
per singolo operatore ([ADR-40](ADR-40-l-amministrazione-usa-unico-token-condiviso.md)):
il registro è l'unica forma di responsabilità per le azioni dal pannello e il
primo posto dove ricostruire cosa è successo dopo un problema.
- *Conseguenze:* o si salvano insieme il cambiamento e la sua riga, o nessuno
dei due; il registro dice *cosa* è cambiato ma non *chi* (vedi
[ADR-40](ADR-40-l-amministrazione-usa-unico-token-condiviso.md)). Il
catalogo degli eventi e le regole di scrittura e lettura sono in
[audit-log.md](../audit-log.md) (`AUD-01` … `AUD-05`).
*Codice:* `app/audit/log.py:write_audit_log`
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more