257 lines
16 KiB
Markdown
257 lines
16 KiB
Markdown
# 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).
|