2026-09-28 12:16:01 +02:00
# 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.
2026-09-28 15:51:13 +02:00
*Codice:* `app/api/routes/bets.py:create_bet` ·
*Test:* — (nessun test verifica `network_unavailable` )
2026-09-28 12:16:01 +02:00
- **[BET-02]** Tutta l'elaborazione di una scommessa, dai controlli all'invio,
avviene tenendo il **blocco per-utente** , lo stesso usato dal prelievo
2026-09-28 15:51:13 +02:00
([WDR-01 ](#prelievo )). Il client della rete usato per la scommessa
(`listener.client` ) viene letto **dopo** aver acquisito il blocco, così
un'attesa sul blocco durante una riconnessione non fa usare una connessione
già chiusa; il controllo di [BET-01] si fa su quel valore.
*Codice:* `app/api/routes/bets.py:create_bet` , `app/tx/locks.py:UserLocks.acquire` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
controllato prima di acquisire il blocco.
2026-09-28 12:16:01 +02:00
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
2026-09-28 15:51:13 +02:00
cifra. Il **costo** è quello del round ([BET-21]):
2026-09-28 12:16:01 +02:00
| ID | Condizione | Codice di errore |
|---|---|---|
2026-09-28 15:51:13 +02:00
| ** [BET-03]** | C'è un round attivo. Se non ce n'è nessuno e le condizioni di apertura sono soddisfatte ([RND-04 ](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** — apertura più la durata fotografata all'apertura (`opened_at + duration_seconds` ), non lo stato nel database — non è ancora passata | `round_closing` |
2026-09-28 12:16:01 +02:00
| ** [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` ) |
2026-09-28 15:51:13 +02:00
| ** [BET-20]** | Se il round ha un tetto al montepremi (`max_round_pool_sats` fotografato all'apertura, [RND-05 ](ciclo-di-vita-del-round.md#0--apertura )) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. *Stato:* da implementare (B-95) · *Oggi:* il tetto non esiste | `round_full` |
| ** [BET-07]** | La somma delle monete confermate e non spese copre il costo della scommessa | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats` ), altrimenti `insufficient_balance` (parametro `required_sats` ), come per il prelievo ([WDR-05 ](#prelievo )). *Stato:* da implementare (B-96) · *Oggi:* sempre `insufficient_balance` , anche quando il saldo mostrato all'utente copre la scommessa |
| ** [BET-08]** | Il costo si copre con al massimo 50 monete (`MAX_TX_INPUTS` ), scelte dalla più grande fino a coprirlo | `too_many_inputs` (parametro `max_inputs` ) |
| ** [BET-09]** | Le monete scelte ([BET-08]) superano il costo di almeno 294 sat (`DUST_LIMIT_SATS` ), che restano come resto | `balance_leaves_no_change` (parametro `required_extra_sats` ) |
2026-09-28 12:16:01 +02:00
| ** [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`
2026-09-28 15:51:13 +02:00
(nessun test verifica `no_round_open` )
2026-09-28 12:16:01 +02:00
2026-09-28 15:51:13 +02:00
Lo stato nel database non basta per la scadenza ([BET-04]) perché lo
scheduler controlla ogni 5 secondi: fino a 5 secondi il round può risultare
ancora `open` oltre la scadenza.
2026-09-28 12:16:01 +02:00
2026-09-29 09:17:52 +02:00
- **[BET-24]** **La selezione garantisce il resto quando il saldo lo
permette.** Le monete si scelgono dalla più grande finché il totale copre il
costo **più** 294 sat (`DUST_LIMIT_SATS` ); [BET-09] rifiuta la scommessa solo
se nemmeno tutte le monete disponibili (entro le 50 di [BET-08]) bastano.
Con monete da 10 PLM e da 1 PLM, una scommessa da 10 PLM usa entrambe e
lascia 1 PLM di resto. Lo stesso vale per il prelievo: la riduzione di
[WDR-09] scatta solo quando nessuna combinazione di monete lascia un resto
sufficiente.
*Codice:* `app/wallet/psbt_builder.py:select_utxos` ·
*Test:* —
*Stato:* da implementare (B-98) · *Oggi:* la selezione si ferma alla prima
moneta che copre il costo, e il resto si misura solo su quelle monete: con
10 + 1 PLM una scommessa da 10 PLM è rifiutata con `balance_leaves_no_change`
e un prelievo da 10 PLM viene ridotto invece di usare la seconda moneta.
2026-09-28 15:51:13 +02:00
- **[BET-21]** Il **costo** di una scommessa è il `bet_amount_sats`
fotografato sul round alla sua apertura
([RND-05 ](ciclo-di-vita-del-round.md#0--apertura )), non il valore corrente
di `round_config` : una modifica da `/admin` vale dal round successivo.
*Codice:* `app/bets/service.py:place_bet` ·
*Test:* —
*Stato:* da implementare (B-95) · *Oggi:* il costo è letto da
`round_config` a ogni scommessa, quindi una modifica vale subito anche per
il round in corso.
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
transazione del database — aperta con `BEGIN IMMEDIATE`
([TX-28 ](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia ))
— di un *compare-and-set* sulla riga del round
2026-09-28 12:16:01 +02:00
(`UPDATE rounds … WHERE status = 'open'` ): se il round non è più aperto in
2026-09-28 15:51:13 +02:00
quell'istante esatto 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. Un `OperationalError` in
questo punto non è un conflitto di scrittura (SQLite non ne produce): è il
blocco in scrittura non ottenuto entro 5 secondi (`database is locked` ).
Anche in quel caso tutto viene annullato prima dell'invio, ma la risposta è
`service_busy` (HTTP 503), perché il round non è necessariamente in
chiusura.
2026-09-28 12:16:01 +02:00
*Codice:* `app/bets/service.py:place_bet` ·
2026-09-29 16:26:01 +02:00
*Test:* `tests/unit/test_bets.py` · `tests/unit/test_sqlite_transactions.py`
2026-09-28 15:51:13 +02:00
- **[BET-22]** `MAX_PARTICIPANTS_PER_ROUND` è minore di
`MAX_PAYOUT_TX_INPUTS` . Il pagamento spende una moneta del montepremi per
ogni scommessa, e una singola transazione di pagamento può spenderne al
massimo 500; bloccare il 401° giocatore all'ingresso ([BET-06]) fa sì che
il numero di monete non impedisca mai di pagare un round. La differenza
lascia spazio ad altre monete presenti sull'indirizzo del montepremi del
round (pagamenti arrivati da fuori).
*Codice:* `app/wallet/psbt_builder.py:MAX_PARTICIPANTS_PER_ROUND` , `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_payout_builder.py` , `tests/unit/test_bets.py`
2026-09-28 12:16:01 +02:00
2026-09-28 15:51:13 +02:00
Oggi il montepremi è un solo indirizzo per tutti i round, e la differenza fra
400 e 500 copre anche i resti accumulati dai pagamenti precedenti; con un
indirizzo per round
([RND-35 ](ciclo-di-vita-del-round.md#0--apertura ), B-80) restano solo i
pagamenti arrivati da fuori. Il numero di ingressi non è l'unica cosa che può
impedire un pagamento: vedi
[ciclo-di-vita-del-round.md ](ciclo-di-vita-del-round.md#4--pagamento ).
2026-09-28 12:16:01 +02:00
### Cosa succede
2026-09-28 15:51:13 +02:00
Comportamento atteso:
2026-09-28 12:16:01 +02:00
```mermaid
flowchart TB
2026-09-28 15:51:13 +02:00
A["Richiesta di scommessa"] --> B["Blocco per-utente acquisito"]
B --> N{"Connessi<br/>alla rete?"}
2026-09-28 12:16:01 +02:00
N -- "No" --> N1["network_unavailable (503)"]
2026-09-28 15:51:13 +02:00
N -- "Sì" --> C["Controlli (tabella sopra)"]
2026-09-28 12:16:01 +02:00
C --> D["Scelta delle monete,<br/>dalla più grande"]
2026-09-28 15:51:13 +02:00
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi del round<br/>resto → indirizzo utente"]
2026-09-28 12:16:01 +02:00
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'"]
2026-09-28 15:51:13 +02:00
F --> G{"Risposta del server"}
G -- "Rifiuto esplicito" --> H["Tutto annullato: monete liberate,<br/>saldo ripristinato,<br/>partecipante rimosso (errore 502)"]
G -- "Nessuna risposta<br/>(timeout, connessione caduta)" --> U["Nulla liberato: partecipante 'building'<br/>(202), decide il riconciliatore"]
G -- "Accettata o già nota" --> I["Partecipante 'broadcast'<br/>(in attesa), inviata anche<br/>agli altri server"]
U --> R{"Il riconciliatore<br/>la trova in rete?"}
R -- "Sì" --> I
R -- "No, e c'è la prova<br/>che non è partita" --> X
2026-09-28 12:16:01 +02:00
I --> J{"Confermata entro<br/>il tempo limite?"}
2026-09-28 15:51:13 +02:00
J -- "No" --> K["Commissione aumentata (RBF),<br/>pagata dal resto dell'utente;<br/>un resto che finirebbe sotto 294 sat<br/>va tutto in commissione"] --> J
J -- "Mai, e c'è la prova<br/>che non è partita" --> X["Abbandonata dal riconciliatore:<br/>monete liberate, partecipante rimosso"]
J -- "Sì (prova merkle del quorum)" --> L["Partecipante confermato:<br/>entra nell'estrazione"]
2026-09-28 12:16:01 +02:00
```
- **[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
2026-09-28 15:51:13 +02:00
l'**indirizzo del montepremi del round** (`rounds.pool_address` ,
[RND-35 ](ciclo-di-vita-del-round.md#0--apertura )), 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` , `app/bets/service.py:place_bet` ·
2026-09-28 12:16:01 +02:00
*Test:* `tests/unit/test_psbt_builder.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-80) · *Oggi:* tutte le scommesse di tutti i
round pagano lo stesso indirizzo (`derive_pool_address` ,
`m/84'/746'/0'/1/0` ).
2026-09-28 12:16:01 +02:00
- **[BET-13]** La **commissione di rete** è la dimensione stimata della
transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da
2026-09-28 15:51:13 +02:00
`/admin` ) ed è **dedotta dal costo** della scommessa: al montepremi arriva
il costo meno la commissione, e l'importo registrato per il partecipante
2026-09-28 12:16:01 +02:00
(`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 è
2026-09-28 15:51:13 +02:00
sempre di almeno 294 sat (`DUST_LIMIT_SATS` ). A differenza del prelievo, la
scommessa non riduce mai il proprio importo per far posto al resto: la
rifiuta ([BET-09]). Le monete scelte devono quindi superare il costo di
almeno 294 sat. Questo resto è l'uscita che l'RBF riduce o elimina, secondo
la regola di
[TX-11 ](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta ):
un resto di 294 sat non regge una riduzione (l'aumento minimo è di 141 sat a
1 sat/vB), quindi garantisce una sola accelerazione, che lo elimina.
2026-09-28 12:16:01 +02:00
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
2026-09-28 15:51:13 +02:00
*Test:* `tests/unit/test_withdrawals.py` (i test del resto delle scommesse
sono lì: `test_a_bet_from_a_balance_equal_to_the_bet_is_refused` ,
`test_a_bet_with_a_dust_limit_of_headroom_is_accepted` ),
`tests/unit/test_psbt_builder.py`
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
partecipante (stato `building` , con l'orario di invio `broadcast_at` ) e la
transazione in corso (stato `building` ). L'ordine dei partecipanti
nell'estrazione è definito in
[ciclo-di-vita-del-round.md ](ciclo-di-vita-del-round.md#3--estrazione ).
2026-09-28 12:16:01 +02:00
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`
2026-09-28 15:51:13 +02:00
- **[BET-16]** Se il server **rifiuta esplicitamente** l'invio
([TX-04 ](transazioni-e-riconciliazione.md#invio-rifiutato )), 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).
2026-09-28 12:16:01 +02:00
*Codice:* `app/bets/service.py:_release_failed_bet` , `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
qualunque errore dell'invio, anche un timeout o una connessione caduta
dopo la scrittura, quando la transazione può essere già in rete: i suoi
sat arrivano al montepremi senza partecipante.
- **[BET-23]** Se l'invio ha un **esito incerto**
([TX-20 ](transazioni-e-riconciliazione.md#invio-rifiutato )), partecipante e
transazione in corso restano `building` con le monete ancora segnate come
spese, e la risposta è HTTP **202** con `round_id` , `bet_txid` ,
`bet_amount_sats` e `status` = `building` . Il partecipante conta per il
limite di [BET-06] e tiene il round in `closing` finché il riconciliatore
non lo promuove o lo abbandona.
*Codice:* `app/bets/service.py:place_bet` , `app/api/routes/bets.py:create_bet` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
un rifiuto ([BET-16]).
- **[BET-17]** Se l'invio è accettato — anche con un rifiuto "già nota"
([TX-04 ](transazioni-e-riconciliazione.md#invio-rifiutato )) — 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
transazione viene poi inviata anche agli altri server
([TX-21 ](transazioni-e-riconciliazione.md#invio-rifiutato )). La risposta è
HTTP 201 con `round_id` , `bet_txid` , `bet_amount_sats` e `status` . Una
scommessa promossa dal riconciliatore scrive `bet_placed` in quel momento
([TX-16 ](transazioni-e-riconciliazione.md#riconciliazione )).
2026-09-28 12:16:01 +02:00
*Codice:* `app/bets/service.py:place_bet` , `app/api/routes/bets.py:create_bet` ·
*Test:* `tests/unit/test_bets.py`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
rifiuto e la transazione va solo al server attivo.
- **[BET-18]** Alla conferma il partecipante passa a `confirmed`
2026-09-28 12:16:01 +02:00
([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
2026-09-28 15:51:13 +02:00
avviene tenendo il **blocco per-utente** ; il client della rete viene letto
e controllato dopo aver acquisito il blocco, come in [BET-02].
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal` , `app/tx/locks.py:UserLocks.acquire` ·
*Test:* — (nessun test verifica `network_unavailable` )
*Stato:* da implementare (B-96) · *Oggi:* `listener.client` viene letto e
controllato prima di acquisire il blocco.
2026-09-28 12:16:01 +02:00
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` |
2026-09-28 15:51:13 +02:00
| ** [WDR-14]** | L'indirizzo di destinazione non è un indirizzo della piattaforma: l'indirizzo del montepremi di un round qualunque (`rounds.pool_address` ), l'indirizzo di riserva (`m/84'/746'/0'/1/0` , [RND-35 ](ciclo-di-vita-del-round.md#0--apertura )), il `fee_address` corrente di `round_config` o quello fotografato su un round. *Stato:* da implementare (B-80) · *Oggi:* nessun controllo; un prelievo verso il montepremi finisce fra le monete di un round | `withdrawal_to_platform_address` |
| ** [WDR-04]** | L'importo è almeno pari al costo di una scommessa **corrente** in `round_config` (`bet_amount_sats` ; non esiste un minimo separato, e non si usa il costo fotografato su un round) | `amount_below_minimum` (parametro `minimum_sats` ) |
2026-09-28 12:16:01 +02:00
| ** [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
2026-09-28 15:51:13 +02:00
solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
2026-09-28 12:16:01 +02:00
- **[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
2026-09-28 15:51:13 +02:00
inferiore a 294 sat (`DUST_LIMIT_SATS` ) — ad esempio 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` ). Come per la scommessa ([BET-14]), questo resto è
l'uscita che l'RBF riduce o elimina
([TX-11 ](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta )).
2026-09-28 12:16:01 +02:00
*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`
2026-09-28 15:51:13 +02:00
- **[WDR-15]** L'importo ridotto da [WDR-09] viene verificato di nuovo
contro il minimo di [WDR-04]: se è sceso sotto `bet_amount_sats` , il
prelievo è rifiutato con `amount_below_minimum` (parametro `minimum_sats` )
prima che si muova qualunque cifra.
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* il minimo è verificato solo
sull'importo richiesto, quindi un prelievo dell'intero saldo può inviare
fino a 294 sat meno del minimo.
- **[WDR-10]** Prima dell'invio, in un'unica transazione del database
([TX-28 ](transazioni-e-riconciliazione.md#il-principio-prima-si-scrive-poi-si-invia )),
le monete spese vengono segnate come tali, il saldo ricalcolato, e si
2026-09-28 12:16:01 +02:00
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`
2026-09-28 15:51:13 +02:00
- **[WDR-11]** Se il server **rifiuta esplicitamente** l'invio
([TX-04 ](transazioni-e-riconciliazione.md#invio-rifiutato )), 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).
2026-09-28 12:16:01 +02:00
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal` , `app/api/routes/withdrawals.py:create_withdrawal` ·
2026-09-28 15:51:13 +02:00
*Test:* `tests/unit/test_withdrawals.py` (nessun test verifica l'evento
`withdrawal_broadcast_failed` )
*Stato:* da implementare (B-76) · *Oggi:* tutto viene annullato per
qualunque errore dell'invio, anche un timeout o una connessione caduta
dopo la scrittura: la transazione può essere partita mentre il prelievo
risulta `failed` e le monete di nuovo spendibili.
- **[WDR-16]** Se l'invio ha un **esito incerto**
([TX-20 ](transazioni-e-riconciliazione.md#invio-rifiutato )), prelievo e
transazione in corso restano `building` con le monete ancora segnate come
spese, e la risposta è HTTP **202** con `txid` , `amount_requested_sats` ,
`amount_sent_sats` e `status` = `building` ; sarà il riconciliatore a
promuoverlo o abbandonarlo.
*Codice:* `app/withdrawals/service.py:request_withdrawal` , `app/api/routes/withdrawals.py:create_withdrawal` ·
*Test:* —
*Stato:* da implementare (B-76) · *Oggi:* un esito incerto è trattato come
un rifiuto ([WDR-11]).
- **[WDR-12]** Se l'invio è accettato — anche con un rifiuto "già nota"
([TX-04 ](transazioni-e-riconciliazione.md#invio-rifiutato )) — 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 transazione viene poi inviata anche agli altri server
([TX-21 ](transazioni-e-riconciliazione.md#invio-rifiutato )). La risposta è
HTTP 201 con `txid` , `amount_requested_sats` , `amount_sent_sats` e
`status` . Alla conferma il prelievo passa a `confirmed`
2026-09-28 12:16:01 +02:00
([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`
2026-09-28 15:51:13 +02:00
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
rifiuto e la transazione va solo al server attivo.
2026-09-28 12:16:01 +02:00
## 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
2026-10-14 12:05:57 +02:00
[limiti-noti.md ](limiti-noti.md )).
2026-09-28 12:16:01 +02:00
Cosa succede a una transazione dopo l'invio — conferma, RBF, abbandono — è
descritto in [transazioni-e-riconciliazione.md ](transazioni-e-riconciliazione.md ).