Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a private chain: the bet left the chain but its participant stayed `confirmed`, and the payout failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs were also credited at 1 confirmation (maturity is 120). Deposits - `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the threshold for a deposit; an output of a platform transaction (any txid of a pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120, recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached). `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no session is open across the lookup. - The quorum must also place the coin deep enough on our own tip (ELC-17). - Confirmed outputs short of their threshold are pending deposits (memory only, replaced at every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal message), never spendable. No scripthash notification announces a maturing deposit, so each new block refreshes only the users that have one, one task per user (DEP-09 now says so). Payout - `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same transaction, and the 70/30 split is computed on what is left. - Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93, with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears the mark and the next attempt verifies again. /admin/rounds returns it. - Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server `listunspent` choice, since that address holds no single round's bets. Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says "1 confirmation" (7 languages, same keys); guides updated. Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0 now set a tip past every threshold; the payout fixtures seed confirmed participants (the new source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the verification, and test_round_pool_address asserts the quorum is asked about the round's address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with the split recomputed, winner gone held and paused, held payouts untouched until the resume, re-verification after it, no quorum, a payout with 1 and with 400 participants against reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored coinbase, no height bound, no verification and no winner hold each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
daa85cf4f0
commit
54dbffb552
42 files changed
+1360
-270
No files matched your search
+28
-40
@@ -345,9 +345,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||||
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
|
||||
`app/deposits/service.py:credit_confirmed_utxos` ·
|
||||
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* si accredita ogni output
|
||||
con `height > 0` (1 conferma), coinbase comprese.
|
||||
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_deposit_confirmations.py`
|
||||
|
||||
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
|
||||
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
|
||||
@@ -360,11 +358,10 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
|
||||
lo stesso motivo l'abbandono di un pagamento non ha input da liberare.
|
||||
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra tutti gli
|
||||
UTXO confermati dell'indirizzo del round (per un round vecchio, del
|
||||
condiviso), lette dal solo server attivo, e non solo fra le uscite delle
|
||||
scommesse dei partecipanti.
|
||||
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
|
||||
Un round aperto prima dell'indirizzo per round (il cui montepremi è
|
||||
l'indirizzo di riserva condiviso) conserva la scelta di prima: le monete
|
||||
confermate dell'indirizzo, lette dal server attivo.
|
||||
|
||||
- **[DAT-12]** `spent_txid` ha tre significati:
|
||||
- `NULL`: l'output è spendibile e conta nel saldo;
|
||||
@@ -443,12 +440,12 @@ restano nella tabella come storico.
|
||||
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` |
|
||||
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` |
|
||||
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` |
|
||||
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
|
||||
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (la ridefinisce la verifica, solo al ribasso) |
|
||||
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
|
||||
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` |
|
||||
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` | `_trigger_payout` |
|
||||
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
|
||||
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
|
||||
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
|
||||
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
|
||||
|
||||
- ~~**[DAT-15]**~~ Assorbito da
|
||||
[RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni
|
||||
@@ -622,10 +619,8 @@ restano nella tabella come storico.
|
||||
database**. Il valore scritto è sempre minore o uguale al precedente.
|
||||
La regola di dominio (quorum, ripartizione su ciò che c'è) è in
|
||||
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82).
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* `pool_amount_sats` non cambia dopo
|
||||
l'estrazione, e un pagamento che non trova tutte le scommesse fallisce con
|
||||
"insufficient pool UTXOs" a ogni tentativo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py`
|
||||
|
||||
- **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67]
|
||||
trova assente la scommessa del **vincitore**: il round resta `paying_out`,
|
||||
@@ -634,8 +629,8 @@ restano nella tabella come storico.
|
||||
([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)),
|
||||
dopo di che la verifica si ripete al tentativo successivo.
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/api/routes/admin.py:resume_lottery` · *Test:* —
|
||||
*Stato:* da implementare (B-82) · *Oggi:* la colonna non esiste.
|
||||
`app/api/routes/admin.py:resume_lottery` ·
|
||||
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
|
||||
|
||||
- **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo
|
||||
di riserva (`m/84'/746'/0'/1/0`), di valore
|
||||
@@ -691,7 +686,7 @@ Una scommessa: un utente in un round.
|
||||
broadcast --> confirmed: _on_bet_confirmed
|
||||
building --> [*]: cancellata (_release_failed_bet / _abandon)
|
||||
broadcast --> [*]: cancellata (_abandon)
|
||||
confirmed --> reorged_out: verifica prima del pagamento (da implementare)
|
||||
confirmed --> reorged_out: verifica prima del pagamento
|
||||
confirmed --> [*]
|
||||
reorged_out --> [*]
|
||||
```
|
||||
@@ -703,7 +698,7 @@ Una scommessa: un utente in un round.
|
||||
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
|
||||
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) |
|
||||
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
|
||||
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` (da implementare) |
|
||||
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` (da `_trigger_payout`), per ogni partecipante tranne il vincitore ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)) |
|
||||
|
||||
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
|
||||
nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione
|
||||
@@ -713,10 +708,9 @@ Una scommessa: un utente in un round.
|
||||
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` ·
|
||||
`app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` ·
|
||||
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
|
||||
`tests/unit/test_reconcile.py`
|
||||
*Stato:* da implementare (B-82, B-76) · *Oggi:* `reorged_out` non esiste (un
|
||||
partecipante confermato la cui scommessa sparisce resta `confirmed`), e
|
||||
qualunque eccezione del broadcast cancella la riga come un rifiuto.
|
||||
`tests/unit/test_reconcile.py`, `tests/unit/test_pool_verification.py`
|
||||
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast
|
||||
cancella la riga come un rifiuto.
|
||||
|
||||
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
|
||||
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non
|
||||
@@ -785,7 +779,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
|
||||
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
|
||||
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
|
||||
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano |
|
||||
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) | Subito, per gli accrediti successivi; gli output già accreditati restano |
|
||||
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
|
||||
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive |
|
||||
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
|
||||
@@ -810,8 +804,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
valore uguale a quello attuale non conta come modifica.
|
||||
*Codice:* `app/api/routes/admin.py:update_config` ·
|
||||
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95), per le ultime due colonne ·
|
||||
*Oggi:* le colonne modificabili sono le prime otto.
|
||||
*Stato:* da implementare (B-95), per l'ultima colonna ·
|
||||
*Oggi:* le colonne modificabili sono le prime nove.
|
||||
|
||||
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
|
||||
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
|
||||
@@ -819,8 +813,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
[DAT-69], che scrivono la propria voce di audit.
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
|
||||
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* le pause automatiche sono
|
||||
`resurrected_tx` (B-78) e `reorg` (B-85); quella di B-82 non esiste ancora.
|
||||
|
||||
- **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di
|
||||
conferme dopo cui un output che paga un utente si accredita ([DAT-10]);
|
||||
@@ -834,10 +826,9 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
|
||||
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
|
||||
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
|
||||
`max_round_pool_sats` non esistono: i depositi usano 1 conferma e non c'è
|
||||
tetto; `draw_confirmations` esiste.
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_migrations.py`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* `max_round_pool_sats` non esiste:
|
||||
non c'è tetto.
|
||||
|
||||
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
|
||||
con `paused` vero vale uno di questi valori, e nessun altro:
|
||||
@@ -861,9 +852,10 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
|
||||
`app/electrum/listener.py:ElectrumListener._record_reorg` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82, B-90) · *Oggi:* si scrivono
|
||||
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore) e
|
||||
`reorg` (il listener); `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive.
|
||||
*Stato:* da implementare (B-90) · *Oggi:* si scrivono
|
||||
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore),
|
||||
`reorg` (il listener) e `payout_blocked` (lo scheduler); `START_PAUSED` non ha
|
||||
ancora chi lo scrive.
|
||||
|
||||
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
|
||||
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
|
||||
@@ -1274,11 +1266,7 @@ letti con una scansione completa.
|
||||
*Codice:* `app/rounds/service.py:winner_share` ·
|
||||
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
|
||||
`app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra
|
||||
tutti gli UTXO confermati dell'indirizzo del round, senza la verifica di
|
||||
[DAT-67]; l'identità con la riserva vale già per i round pagati con tutti
|
||||
e soli i loro ingressi.
|
||||
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
|
||||
|
||||
- ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa
|
||||
delle scommesse in volo prima dell'estrazione è comportamento del round. Il
|
||||
|
||||
Reference in new issue
Block a user