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>
This commit is contained in:
davideandClaude Opus 5.5 committed 2026-09-28 12:16:01 +02:00
1 parent 526a649c8b
commit 61efbb0e37
29 files changed
+6964 -310

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
# Scommesse e prelievi
Fasi 3 e 5 del [funzionamento generale](come-funziona.md): le due operazioni
con cui l'utente spende il proprio saldo. Hanno la stessa forma — una
transazione dal suo indirizzo verso una destinazione, con il resto che torna a
lui — e condividono le stesse regole di sicurezza.
I requisiti usano i prefissi `BET` (scommesse) e `WDR` (prelievi), secondo la
convenzione di [processo-di-modifica.md](processo-di-modifica.md). Il formato
delle risposte e il catalogo completo dei codici di errore sono in
[api.md](api.md); cosa succede a una transazione dopo l'invio in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).
Costanti citate in questo documento (tutte in `app/wallet/psbt_builder.py`):
| Costante | Valore | Significato |
|---|---|---|
| `DUST_LIMIT_SATS` | 294 sat | Resto minimo di ogni transazione utente |
| `MAX_TX_INPUTS` | 50 | Monete massime in ingresso per scommessa o prelievo |
| `MAX_PARTICIPANTS_PER_ROUND` | 400 | Partecipanti massimi per round |
| `MAX_PAYOUT_TX_INPUTS` | 500 | Monete massime in ingresso per il pagamento del round |
## Scommessa
### Quando è accettata
Prima dei controlli valgono due condizioni di contorno:
- **[BET-01]** Se il server non è connesso alla rete, la scommessa è
rifiutata con `network_unavailable` (HTTP 503) prima di qualunque altro
controllo.
*Codice:* `app/api/routes/bets.py:create_bet`
- **[BET-02]** Tutta l'elaborazione di una scommessa, dai controlli all'invio,
avviene tenendo il **blocco per-utente**, lo stesso usato dal prelievo
([WDR-01](#prelievo)).
*Codice:* `app/api/routes/bets.py:create_bet`, `app/tx/locks.py:UserLocks.acquire`
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
rifiutata con il codice indicato (HTTP 400), prima che si muova qualunque
cifra:
| ID | Condizione | Codice di errore |
|---|---|---|
| **[BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-03](ciclo-di-vita-del-round.md#0--apertura)), è la scommessa stessa ad aprirlo | `no_round_open` |
| **[BET-04]** | Il round è in stato `open` e la sua scadenza non è ancora passata | `round_closing` |
| **[BET-05]** | L'utente non ha già una scommessa in questo round (in qualunque stato) | `already_betting` |
| **[BET-06]** | Il round ha meno di 400 partecipanti (`MAX_PARTICIPANTS_PER_ROUND`), contando anche quelli non ancora confermati | `round_full` (parametro `max_participants`) |
| **[BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa (`bet_amount_sats`) | `insufficient_balance` (parametro `required_sats`) |
| **[BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS`), scelte dalla più grande | `too_many_inputs` (parametro `max_inputs`) |
| **[BET-09]** | Dopo la scommessa resta un resto di almeno 294 sat (`DUST_LIMIT_SATS`) | `balance_leaves_no_change` (parametro `required_extra_sats`) |
| **[BET-10]** | Il costo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
*Codice:* `app/bets/service.py:place_bet`, `app/rounds/service.py:round_accepts_bets`,
`app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
La **scadenza** è quella del round stesso (apertura + durata fissata
all'apertura), non il suo stato nel database: lo scheduler controlla ogni 5
secondi, quindi per qualche istante il round potrebbe risultare ancora
"aperto" oltre la scadenza.
- **[BET-11]** La scadenza viene verificata due volte: all'arrivo della
richiesta ([BET-04]) e di nuovo dopo aver costruito e firmato la
transazione. L'iscrizione al round viene poi salvata nella stessa
transazione del database di un *compare-and-set* sulla riga del round
(`UPDATE rounds … WHERE status = 'open'`): se il round non è più aperto in
quell'istante esatto, o il database segnala un conflitto di scrittura, tutto
viene annullato prima dell'invio e la scommessa è rifiutata con
`round_closing`. Così nessuna scommessa può finire nel montepremi senza
essere inclusa nell'estrazione.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
Il limite di **400 partecipanti** esiste perché il pagamento deve spendere una
moneta del montepremi per ogni scommessa, e una singola transazione di
pagamento può contenerne al massimo 500 (`MAX_PAYOUT_TX_INPUTS`); la
differenza lascia spazio ai resti accumulati nel montepremi dai round
precedenti. Bloccare il 401° giocatore all'ingresso garantisce che ogni round
si possa sempre pagare.
### Cosa succede
```mermaid
flowchart TB
A["Richiesta di scommessa"] --> N{"Connessi<br/>alla rete?"}
N -- "No" --> N1["network_unavailable (503)"]
N -- "Sì" --> B["Blocco per-utente acquisito"]
B --> C["Controlli (tabella sopra)"]
C --> D["Scelta delle monete,<br/>dalla più grande"]
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi<br/>resto → indirizzo utente"]
E --> CAS{"Scadenza non passata e<br/>round ancora 'open'<br/>(compare-and-set)?"}
CAS -- "No" --> CAS1["Annullato prima dell'invio:<br/>round_closing"]
CAS -- "Sì" --> F["Salvataggio PRIMA dell'invio:<br/>monete segnate come spese,<br/>partecipante 'building'"]
F --> G{"La rete accetta<br/>l'invio?"}
G -- "No" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
G -- "Sì" --> I["Partecipante 'broadcast'<br/>(in attesa)"]
I --> J{"Confermata entro<br/>il tempo limite?"}
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente"] --> J
J -- "Mai, e la rete non<br/>la conosce più" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
J -- "Sì" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
```
- **[BET-12]** La transazione spende monete **intere** dell'utente, scelte
dalla più grande; tutti gli ingressi segnalano la sostituibilità (BIP125,
`RBF_SEQUENCE`). Ha due uscite: il costo meno la commissione verso
l'indirizzo del montepremi, e il resto (monete spese meno il costo) verso
l'indirizzo dell'utente.
*Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py`
- **[BET-13]** La **commissione di rete** è la dimensione stimata della
transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da
`/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arrivano
10 PLM meno la commissione, e l'importo registrato per il partecipante
(`bet_amount_sats`) è proprio questo netto. Per questo il montepremi si
calcola sommando gli importi effettivi delle scommesse, non moltiplicando il
numero di giocatori per il costo.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_rounds_route.py`
- **[BET-14]** Il **resto** torna sempre allo stesso indirizzo dell'utente ed è
sempre di almeno 294 sat: senza un'uscita di resto non si potrebbe aumentare
la commissione in caso di ritardo. A differenza del prelievo, la scommessa
non riduce mai il proprio importo per far posto al resto: la rifiuta
([BET-09]). Il saldo di un utente che scommette deve quindi essere
strettamente maggiore del costo.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
- **[BET-15]** **Prima si scrive, poi si invia.** Prima dell'invio, in
un'unica transazione del database, le monete spese vengono segnate come tali
(con il txid della scommessa), il saldo viene ricalcolato, e si registrano il
partecipante (stato `building`, con l'orario di invio che ne determina
l'ordine nell'estrazione) e la transazione in corso (stato `building`).
Dettagli in
[transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia).
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-16]** Se l'invio fallisce per qualunque motivo, tutto torna com'era:
monete liberate, partecipante e transazione in corso **cancellati**, saldo
ricalcolato, evento `bet_broadcast_failed` nel registro di audit, notifica
SSE; la risposta è `broadcast_failed` (HTTP 502).
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-17]** Se l'invio riesce, il partecipante passa a `broadcast`, la
transazione in corso a `pending`, si scrive `bet_placed` (txid e importo
netto) e si invia una notifica SSE. La risposta è HTTP 201 con `round_id`,
`bet_txid`, `bet_amount_sats` e `status`.
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
- **[BET-18]** Alla prima conferma il partecipante passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)). Solo le scommesse
**confermate** partecipano all'estrazione; il round, allo scadere, aspetta
che tutte quelle già inviate confermino (vedi
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)).
*Codice:* `app/bets/confirmation.py:_on_bet_confirmed` ·
*Test:* `tests/unit/test_confirmation.py`
- **[BET-19]** Tutte le scommesse hanno **la stessa probabilità** di vincere,
indipendentemente dall'importo netto e da tutto il resto
([RND-17](ciclo-di-vita-del-round.md#3--estrazione)).
*Codice:* `app/rounds/draw.py:draw_winner` ·
*Test:* `tests/unit/test_draw.py`
## Prelievo
Il prelievo (`POST /withdrawals`, con `external_address` e `amount_sats`) è
l'**unico** modo per portare fondi fuori dalla piattaforma, verso un indirizzo
esterno scelto dall'utente.
- **[WDR-01]** Come per la scommessa, un prelievo senza connessione alla rete
è rifiutato con `network_unavailable` (HTTP 503), e tutta l'elaborazione
avviene tenendo il **blocco per-utente**.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/tx/locks.py:UserLocks.acquire`
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
HTTP 400 prima che si muova qualunque cifra:
| ID | Condizione | Codice di errore |
|---|---|---|
| **[WDR-02]** | L'indirizzo di destinazione è un indirizzo PLM bech32: inizia con `plm1` ed è decodificabile. Un indirizzo di un'altra catena (ad esempio `bc1…`) o in formato legacy (`P…`) è rifiutato | `invalid_address` |
| **[WDR-03]** | L'indirizzo di destinazione non è l'indirizzo di deposito dell'utente stesso | `withdrawal_to_own_address` |
| **[WDR-04]** | L'importo è almeno pari al costo corrente di una scommessa (`bet_amount_sats`; non esiste un minimo separato) | `amount_below_minimum` (parametro `minimum_sats`) |
| **[WDR-05]** | La somma delle monete confermate e non spese copre l'importo | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`) |
| **[WDR-06]** | L'importo si copre con al massimo 50 monete (`MAX_TX_INPUTS`) | `too_many_inputs` (parametro `max_inputs`) |
| **[WDR-07]** | L'importo meno la commissione di rete resta positivo e di almeno 294 sat | `amount_below_network_fee`, `amount_below_dust_limit` |
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/wallet/address.py:is_valid_plm_address`,
`app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
La struttura è identica alla scommessa: transazione firmata dall'indirizzo
dell'utente verso l'indirizzo esterno, resto all'utente, **commissione dedotta
dall'importo prelevato**, salvataggio prima dell'invio, annullamento completo
se la rete rifiuta, RBF se non conferma in tempo.
- **[WDR-08]** La transazione spende monete intere dell'utente (dalla più
grande, ingressi sostituibili BIP125); all'indirizzo esterno arriva
l'importo meno la commissione di rete, il resto torna all'indirizzo
dell'utente.
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
*Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_withdrawals.py`
- **[WDR-09]** **Resto garantito.** Se l'importo richiesto lascerebbe un resto
inferiore a 294 sat — tipicamente il prelievo dell'intero saldo —, l'importo
viene ridotto quanto basta perché il resto sia esattamente 294 sat, che
restano sul conto dell'utente. Chi preleva tutto riceve quindi il saldo meno
294 sat meno la commissione. Il database registra sia l'importo richiesto
(`amount_requested_sats`) sia quello effettivamente inviato al destinatario,
al netto della commissione (`amount_sent_sats`).
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction`, `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_psbt_builder.py`
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database, le
monete spese vengono segnate come tali, il saldo ricalcolato, e si
registrano il prelievo e la transazione in corso, entrambi in stato
`building`.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
- **[WDR-11]** Se l'invio fallisce, le monete vengono liberate e il saldo
ricalcolato, ma — a differenza della scommessa — il prelievo **non viene
cancellato**: resta come `failed` (senza txid), così come la transazione in
corso (con il motivo in `failure_reason`); si scrive
`withdrawal_broadcast_failed`, si invia una notifica SSE e la risposta è
`broadcast_failed` (HTTP 502).
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
- **[WDR-12]** Se l'invio riesce, il prelievo passa a `broadcast`, la
transazione in corso a `pending`, si scrive `withdrawal_sent` (txid, importo
inviato, indirizzo esterno) e si invia una notifica SSE. La risposta è
HTTP 201 con `txid`, `amount_requested_sats`, `amount_sent_sats` e `status`.
Alla prima conferma il prelievo passa a `confirmed`
([TX-07](transazioni-e-riconciliazione.md#conferma)).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/withdrawals/confirmation.py:_on_withdrawal_confirmed` ·
*Test:* `tests/unit/test_withdrawals.py`
## Scommessa e prelievo insieme
Scommessa e prelievo spendono le stesse monete, quindi non devono mai sceglierne
una in comune. Questo è garantito da due meccanismi:
1. un **blocco per-utente** ([BET-02](#quando-è-accettata),
[WDR-01](#prelievo)): la costruzione di una scommessa e quella di un
prelievo dello stesso utente non possono mai essere in corso nello stesso
momento;
2. la selezione delle monete **salta quelle già segnate come spese**, anche se
la transazione che le spende non è ancora confermata
([BET-07](#quando-è-accettata), [WDR-05](#prelievo)).
- **[WDR-13]** Non c'è altro vincolo: un prelievo è accettato
anche mentre una scommessa attende conferma (e viceversa), purché le monete
confermate e ancora libere lo coprano. Congelare il resto del saldo per un blocco non
aggiungerebbe alcuna sicurezza.
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_withdrawals.py`
Il blocco per-utente vive nella memoria del processo: vale finché il server è
un solo processo (vedi [architettura.md](architettura.md) e
[decisioni-e-limiti.md](decisioni-e-limiti.md)).
Cosa succede a una transazione dopo l'invio — conferma, RBF, abbandono — è
descritto in [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md).