Files
plm-lottery/docs/scommesse-e-prelievi.md
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00

24 KiB

Scommesse e prelievi

Fasi 3 e 5 del funzionamento generale: 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. Il formato delle risposte e il catalogo completo dei codici di errore sono in api.md; cosa succede a una transazione dopo l'invio in 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). 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), è 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) diverso da 0, la somma degli importi netti dei partecipanti del round, contando anche quelli non ancora confermati, è minore del tetto. 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). 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.

  • [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.

  • [BET-21] Il costo di una scommessa è il bet_amount_sats fotografato sul round alla sua apertura (RND-05), non il valore corrente di round_config: una modifica da /admin vale dal round successivo. Codice: app/bets/service.py:place_bet · Test: tests/unit/test_round_snapshot.py::test_every_bet_of_a_round_pays_the_roundd_cost_whatever_is_edited_meanwhile

  • [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) — 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 · tests/unit/test_sqlite_transactions.py

  • [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

Con un indirizzo per round (RND-35) la differenza fra 400 e 500 non si consuma più con i resti dei pagamenti precedenti: 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.

Cosa succede

Comportamento atteso:

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), 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, tests/unit/test_round_pool_address.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 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: 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. Dettagli in transazioni-e-riconciliazione.md. Codice: app/bets/service.py:place_bet · Test: tests/unit/test_bets.py
  • [BET-16] Se il server rifiuta esplicitamente l'invio (TX-04), 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, tests/unit/test_broadcast_outcome.py
  • [BET-23] Se l'invio ha un esito incerto (TX-20), 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: tests/unit/test_broadcast_outcome.py
  • [BET-17] Se l'invio è accettato — anche con un rifiuto "già nota" (TX-04) — 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). 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). Codice: app/bets/service.py:place_bet, app/api/routes/bets.py:create_bet · Test: tests/unit/test_bets.py, tests/unit/test_broadcast_outcome.py
  • [BET-18] Alla conferma il partecipante passa a confirmed (TX-07). 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). 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). 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), il fee_address corrente di round_config o quello fotografato su un round. Codice: app/withdrawals/service.py:request_withdrawal · Test: tests/unit/test_round_pool_address.py 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). 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), 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), 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, tests/unit/test_broadcast_outcome.py
  • [WDR-16] Se l'invio ha un esito incerto (TX-20), 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: tests/unit/test_broadcast_outcome.py
  • [WDR-12] Se l'invio è accettato — anche con un rifiuto "già nota" (TX-04) — 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). La risposta è HTTP 201 con txid, amount_requested_sats, amount_sent_sats e status. Alla conferma il prelievo passa a confirmed (TX-07). Codice: app/withdrawals/service.py:request_withdrawal, app/withdrawals/confirmation.py:_on_withdrawal_confirmed · Test: tests/unit/test_withdrawals.py, tests/unit/test_broadcast_outcome.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, WDR-01): 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, WDR-05).
  • [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 e limiti-noti.md).

Cosa succede a una transazione dopo l'invio — conferma, RBF, abbandono — è descritto in transazioni-e-riconciliazione.md.