379 lines
25 KiB
Markdown
379 lines
25 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` ·
|
|
*Test:* — (nessun test verifica `network_unavailable`)
|
|
- **[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)). 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.
|
|
|
|
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. Il **costo** è quello del round ([BET-21]):
|
|
|
|
| ID | Condizione | Codice di errore |
|
|
|---|---|---|
|
|
| **[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` |
|
|
| **[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-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`) |
|
|
| **[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`
|
|
(nessun test verifica `no_round_open`)
|
|
|
|
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.
|
|
|
|
La selezione si ferma alla prima moneta che porta il totale a coprire il
|
|
costo ([BET-08]): il resto di [BET-09] si misura su quelle monete, non
|
|
sull'intero saldo.
|
|
|
|
- **[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.
|
|
- **[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 — 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
|
|
(`UPDATE rounds … WHERE status = 'open'`): se il round non è più aperto in
|
|
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.
|
|
*Codice:* `app/bets/service.py:place_bet` ·
|
|
*Test:* `tests/unit/test_bets.py`
|
|
*Stato:* da implementare (B-88) · *Oggi:* la transazione si apre con la
|
|
prima scrittura, non con `BEGIN IMMEDIATE`; un `OperationalError` viene
|
|
trattato come un conflitto di scrittura e risponde `round_closing`.
|
|
- **[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`
|
|
|
|
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).
|
|
|
|
### Cosa succede
|
|
|
|
Comportamento atteso:
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
A["Richiesta di scommessa"] --> B["Blocco per-utente acquisito"]
|
|
B --> N{"Connessi<br/>alla rete?"}
|
|
N -- "No" --> N1["network_unavailable (503)"]
|
|
N -- "Sì" --> C["Controlli (tabella sopra)"]
|
|
C --> D["Scelta delle monete,<br/>dalla più grande"]
|
|
D --> E["Transazione firmata:<br/>costo meno commissione → montepremi del round<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{"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
|
|
I --> J{"Confermata entro<br/>il tempo limite?"}
|
|
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"]
|
|
```
|
|
|
|
- **[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 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` ·
|
|
*Test:* `tests/unit/test_psbt_builder.py`
|
|
*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`).
|
|
- **[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 arriva
|
|
il costo 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 (`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.
|
|
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
|
|
*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`
|
|
- **[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 `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).
|
|
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 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).
|
|
*Codice:* `app/bets/service.py:_release_failed_bet`, `app/api/routes/bets.py:create_bet` ·
|
|
*Test:* `tests/unit/test_bets.py`
|
|
*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)).
|
|
*Codice:* `app/bets/service.py:place_bet`, `app/api/routes/bets.py:create_bet` ·
|
|
*Test:* `tests/unit/test_bets.py`
|
|
*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`
|
|
([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**; 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.
|
|
|
|
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-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`) |
|
|
| **[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
|
|
solo se la rete rifiuta esplicitamente, 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 (`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)).
|
|
*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-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
|
|
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 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).
|
|
*Codice:* `app/withdrawals/service.py:_release_failed_withdrawal`, `app/api/routes/withdrawals.py:create_withdrawal` ·
|
|
*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`
|
|
([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`
|
|
*Stato:* da implementare (B-76) · *Oggi:* un "già nota" è trattato come un
|
|
rifiuto e la transazione va solo al server attivo.
|
|
|
|
## 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).
|