Close the smaller state inconsistencies (B-96, BET-02, BET-07, WDR-01, WDR-15, TX-16, TX-18, TX-27, TX-29, DEP-15, DEP-16, AUD-30, AUD-46, DAT-22)

- bet/withdrawal routes read listener.client after taking the user lock
- a reduced full-balance withdrawal is checked against the minimum again
- a bet covered only by pending balance answers balance_pending_confirmation
- a recovered bet/withdrawal/payout writes bet_placed/withdrawal_sent/payout_sent
- an abandoned payout clears winner/fee/reserve amounts with payout_txid
- the reconciler pings SSE only for rows it changed
- closing_stalled every 30 minutes past the deadline; closing_waiting_since on /rounds/current
- one refresh per user at a time; a failed refresh no longer ends the Electrum session

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-18 01:08:14 +02:00
1 parent 24e5fc3d8b
commit 9520c80dcf
23 files changed
+445 -151

No files matched your search

+1 -17
View File
@@ -1,7 +1,7 @@
# BUGS.md — open findings
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
(B-92, B-93, B-96). A finding is removed from this file once it is fixed, and
(B-92, B-93). A finding is removed from this file once it is fixed, and
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
convention each entry gets its own commit with its own regression test, and the `B-nn`
marker goes in a comment next to the fix.
@@ -56,19 +56,3 @@ kills after 10s); an older image cannot start on a newer DB; images are untagged
Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task
cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`;
documented pre-deploy backup and downgrade procedure.
### B-96 — Smaller state inconsistencies
A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned
payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writes no
`payout_sent` and a promoted bet no `bet_placed`; the reconciler pings SSE for every
row it checks; concurrent refreshes of one user can collide on the unique outpoint
and end the Electrum session; `listener.client` is read outside the per-user lock;
a bet covered only by pending change gets `insufficient_balance` instead of
`balance_pending_confirmation`. A round waiting in `closing` on a bet that never
leaves the mempool gives no signal at all.
Fix: each as described, one regression test per item; for the `closing` wait, a
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
on `GET /rounds/current` (spec: TX-29).
+2 -2
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 738 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 749 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 738 tests
python -m pytest # all 749 tests
python -m pytest tests/unit/test_hd.py # one file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
```
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
738 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
749 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
the pending-inclusive balance calculation, and the SSE push channel. No
automated integration tests against a live Electrum connection — mainnet
+10 -7
View File
@@ -26,16 +26,19 @@ async def create_bet(
session: AsyncSession = Depends(get_session),
) -> BetResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
async with request.app.state.user_locks.acquire(user.id):
# B-96 (BET-02): read after the lock is taken — a wait on it across a reconnection
# must not hand place_bet the connection that closed while this request queued.
client = listener.client
if client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
try:
participant = await place_bet(session, listener.client, user, network=listener)
participant = await place_bet(session, client, user, network=listener)
except BetError as exc:
# A rejected broadcast isn't the client's fault — it's the network
# refusing our transaction, so it answers 502 rather than 400 (B-07).
+3
View File
@@ -119,6 +119,8 @@ class CurrentRoundResponse(BaseModel):
# waiting for a block" rather than a countdown implying a bounded wait — this
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
draw_waiting_since: str | None = None
# B-96 (TX-29): since when the round has waited in "closing" for in-flight bets; only then.
closing_waiting_since: str | None = None
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
# revealed).
@@ -233,6 +235,7 @@ async def current_round(
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
closing_waiting_since=isoformat_utc(round_.closed_at) if round_.status == "closing" and round_.closed_at else None,
server_seed_hash=round_.server_seed_hash,
participants_hash=round_.participants_hash,
draw_height=round_.draw_height,
+9 -7
View File
@@ -32,17 +32,19 @@ async def create_withdrawal(
session: AsyncSession = Depends(get_session),
) -> WithdrawalResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
async with request.app.state.user_locks.acquire(user.id):
# B-96 (WDR-01): read after the lock is taken, as in create_bet.
client = listener.client
if client is None:
raise http_error(
status.HTTP_503_SERVICE_UNAVAILABLE,
"network_unavailable",
"not connected to the network, try again shortly",
)
try:
withdrawal = await request_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats, network=listener
session, client, user, body.external_address, body.amount_sats, network=listener
)
except WithdrawalError as exc:
code = (
+14 -2
View File
@@ -16,7 +16,7 @@ from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.tx.versions import delete_versions, record_version
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
from app.wallet.balance import recompute_balance
from app.wallet.balance import compute_pending_balance, recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
@@ -125,7 +125,19 @@ async def place_bet(
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
)
).all()
if sum(u.amount_sats for u in unspent) < bet_amount:
confirmed_sats = sum(u.amount_sats for u in unspent)
if confirmed_sats < bet_amount:
# B-96 (BET-07, DEP-13): the balance on screen includes unconfirmed change and deposits
# short of their threshold; a bet that balance would cover gets "wait for the
# confirmation", as a withdrawal does, instead of a flat "insufficient".
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
if has_pending and pending_inclusive_sats >= bet_amount:
raise BetError(
"balance_pending_confirmation",
"the bet cost is covered by your pending balance, which has not confirmed yet",
pending_sats=pending_inclusive_sats - confirmed_sats,
)
raise BetError("insufficient_balance", "insufficient balance", required_sats=bet_amount)
user_key = derive_user_key(user.derivation_index)
+19 -1
View File
@@ -119,6 +119,7 @@ class ElectrumListener:
self._allow_no_quorum = allow_no_quorum
self._endpoint_index = 0
self._restore_checked = False
self._refresh_locks: dict[int, asyncio.Lock] = {}
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
# can't be garbage-collected mid-flight, and so its exception (if any) is
@@ -1143,9 +1144,26 @@ class ElectrumListener:
scripthash, _status = await queue.get()
user_id = self._scripthash_to_user.get(scripthash)
if user_id is not None:
await self.refresh_user(user_id, scripthash)
try:
await self.refresh_user(user_id, scripthash)
except asyncio.CancelledError:
raise
except Exception:
# B-96 (DEP-16): one user's failed refresh (a coin already recorded, a
# corroboration error) ends neither the session nor the other users'
# notifications; a dropped connection ends it through wait_closed(), and
# this user is picked up again by the next refresh or the periodic sweep.
logger.warning("refresh of user %s failed after a notification", user_id, exc_info=True)
async def refresh_user(self, user_id: int, scripthash: str) -> None:
"""One refresh of a user at a time (B-96, DEP-15): a notification, the re-subscription
and the periodic sweep can ask together, and two of them reading the same new coin would
both try to credit it. The lock is the refresh's own, not the bet/withdrawal one, so a
corroboration in progress never delays a bet. Other users refresh in parallel."""
async with self._refresh_locks.setdefault(user_id, asyncio.Lock()):
await self._refresh_user_locked(user_id, scripthash)
async def _refresh_user_locked(self, user_id: int, scripthash: str) -> None:
"""Three phases, so no DB session is held across a network call (B-18),
same shape as _trigger_payout: read what's needed, corroborate every
balance-moving candidate against other servers — new credits (B-59) as
+43
View File
@@ -47,6 +47,9 @@ _PAYOUT_RETRY_INTERVAL_SECONDS = 60
# corroboration failure. These only make an already-long wait *observable*, they
# never cut it short.
_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60
# B-96 (TX-29, AUD-46): how long past its deadline a round may sit in `closing` on an in-flight bet before
# `closing_stalled` is written, and how often it repeats. The wait itself has no limit (ADR-51).
CLOSING_STALL_THRESHOLD_SECONDS = 1800
_DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
@@ -143,10 +146,50 @@ class RoundScheduler:
)
)
if pending_count:
await self._note_closing_stall(round_id, deadline)
return # wait for in-flight bets to confirm before closing; stays "closing"
await self._close_and_draw(round_id)
async def _note_closing_stall(self, round_id: int, deadline: datetime) -> None:
"""B-96 (TX-29, AUD-46): the wait in `closing` is unbounded by design, so it is made
visible — one `closing_stalled` row per CLOSING_STALL_THRESHOLD_SECONDS once the round has
been past its deadline for that long. The latest row is the clock of the next one (read
from the log, not from memory, so a restart neither repeats nor anticipates it)."""
now = datetime.now(timezone.utc)
seconds_past = (now - deadline).total_seconds()
if seconds_past < CLOSING_STALL_THRESHOLD_SECONDS:
return
async with self._session_factory() as session:
last = await session.scalar(
select(AuditLog.created_at)
.where(AuditLog.event_type == "closing_stalled", AuditLog.round_id == round_id)
.order_by(AuditLog.id.desc())
.limit(1)
)
if last is not None and now < last.replace(tzinfo=timezone.utc) + timedelta(
seconds=CLOSING_STALL_THRESHOLD_SECONDS
):
return
txids = list(
await session.scalars(
select(RoundParticipant.bet_txid)
.where(
RoundParticipant.round_id == round_id,
RoundParticipant.status.in_(("building", "broadcast")),
)
.order_by(RoundParticipant.id)
)
)
await write_audit_log(
session,
"closing_stalled",
{"in_flight": len(txids), "txids": txids, "seconds_past_deadline": int(seconds_past)},
round_id=round_id,
)
await session.commit()
logger.warning("round %s has waited %ss past its deadline for %s in-flight bet(s)", round_id, int(seconds_past), len(txids))
async def _close_and_draw(self, round_id: int) -> None:
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
participant) or commit to the draw — fix the participant list, its fingerprint and the
+40 -2
View File
@@ -214,8 +214,9 @@ async def reconcile_once(
await _abandon(session, row, payload)
changed = True
await session.commit()
resolved += changed
broadcaster.publish()
if changed: # B-96 (TX-27): a row that was only examined moved nothing to refetch
resolved += 1
broadcaster.publish()
return resolved
@@ -360,6 +361,9 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
"""The tx did make it onto the chain before we died — finish what phase 2 of
place_bet/request_withdrawal would have done."""
row.status = "pending"
# B-96 (TX-16, AUD-30): besides pending_tx_recovered, the event the successful send would have
# written — a recovered bet, withdrawal or payout is otherwise absent from the audit trail.
sent_event: tuple[str, dict, int | None] | None = None
if row.kind == "bet":
participant = await session.scalar(
select(RoundParticipant).where(
@@ -368,10 +372,39 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
)
if participant is not None and participant.status == "building":
participant.status = "broadcast"
sent_event = (
"bet_placed",
{"txid": row.current_txid, "amount_sats": participant.bet_amount_sats},
row.user_id,
)
elif row.kind == "withdrawal" and row.withdrawal_id is not None:
withdrawal = await session.get(Withdrawal, row.withdrawal_id)
if withdrawal is not None and withdrawal.status == "building":
withdrawal.status = "broadcast"
sent_event = (
"withdrawal_sent",
{
"txid": row.current_txid,
"amount_sent_sats": withdrawal.amount_sent_sats,
"external_address": withdrawal.external_address,
},
row.user_id,
)
elif row.kind == "payout" and row.round_id is not None:
round_ = await session.get(Round, row.round_id)
if round_ is not None:
sent_event = (
"payout_sent",
{
"txid": row.current_txid,
"winner_sats": round_.winner_amount_sats,
"commission_sats": round_.fee_amount_sats,
},
round_.winner_user_id,
)
if sent_event is not None:
event, payload, event_user_id = sent_event
await write_audit_log(session, event, payload, user_id=event_user_id, round_id=row.round_id)
await write_audit_log(
session,
"pending_tx_recovered",
@@ -418,6 +451,11 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
round_ = await session.get(Round, row.round_id) if row.round_id else None
if round_ is not None and round_.status == "paying_out":
round_.payout_txid = None
# B-96 (TX-18, DAT-22): the amounts belong to the attempt that no longer exists; the
# next attempt writes its own.
round_.winner_amount_sats = None
round_.fee_amount_sats = None
round_.bump_reserve_sats = None
logger.error(
"round %s payout tx %s vanished — round needs operator attention", round_.id, row.current_txid
)
+10
View File
@@ -144,6 +144,16 @@ async def request_withdrawal(
except InsufficientFundsError as exc:
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
# B-96 (WDR-15): the reduction above can take a full-balance withdrawal a dust limit under
# the minimum that was checked on the requested amount.
moved_sats = built.recipient_sats + built.fee_sats
if moved_sats < config.bet_amount_sats:
raise WithdrawalError(
"amount_below_minimum",
f"amount below the minimum of {config.bet_amount_sats} sats",
minimum_sats=config.bet_amount_sats,
)
# Persist the intent before broadcasting, and only promote the rows once the
# network has accepted the tx — same two-phase shape as place_bet (B-08).
spent_by_key = {(u.txid, u.vout): u for u in unspent}
+2 -14
View File
@@ -117,8 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
modo: torna alla schermata di login.
*Codice:* `app/auth/dependencies.py:get_current_user` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
dell'header è intercettata da FastAPI prima del codice applicativo e
risponde con il corpo non strutturato `{"detail": "Not authenticated"}`.
- **[API-07]** Autenticazione **facoltativa**: senza header, con un header che
non inizia con `Bearer `, con un token invalido o superato, la richiesta è
@@ -136,7 +134,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
(vedi [sicurezza.md](sicurezza.md)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
strutturato `{"detail": "invalid admin token"}`.
- **[API-09]** Intestazioni di cache:
- le quattro pagine (`/`, `/admin`, `/guida`, `/report-bug`) rispondono con
@@ -265,8 +262,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/routes/admin.py:update_bug_report_status`,
`app/api/routes/rounds.py:round_stream`, `app/main.py` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la
mostra così com'è.
- **[API-17]** Il client traduce `code` con la chiave `error.<code>` di
`app/static/i18n.js`, passando `params` all'interpolazione e aggiungendo per
@@ -393,8 +388,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/users.py:change_password`,
`app/auth/rate_limit.py:RateLimiter` ·
*Test:* `tests/unit/test_users.py`
limite di frequenza (la password attuale si può provare senza limiti) e
nessuna voce di audit.
#### GET /users/me/last-round-result
@@ -460,8 +453,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/bets.py:create_bet`,
`app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_round_snapshot.py`
*Stato:* da implementare (B-96) · *Oggi:* un costo coperto solo da resto non
confermato risponde `insufficient_balance`.
#### POST /withdrawals
@@ -507,8 +498,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
*Codice:* `app/api/routes/withdrawals.py:create_withdrawal`,
`app/withdrawals/service.py:request_withdrawal` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-96) · *Oggi:* l'importo ridotto di un prelievo
dell'intero saldo non è riconfrontato con il minimo.
### Round
@@ -539,6 +528,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `draw_block_height` | intero o `null` | altezza del blocco usato per l'estrazione |
| `draw_block_hash` | stringa o `null` | hash di quel blocco; è **uno** degli ingressi del seme, non il seme (vedi [API-45](#get-roundsround_idproof)) |
| `draw_waiting_since` | stringa ISO o `null` | inizio dell'attesa del blocco; valorizzato **solo** mentre `status` è `drawing` |
| `closing_waiting_since` | stringa ISO o `null` | da quando il round attende in `closing` le scommesse in volo ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); valorizzato **solo** mentre `status` è `closing` |
| `server_seed_hash` | stringa o `null` | impegno del server sul segreto del round, pubblicato dall'apertura; `null` per un round aperto prima di B-73 |
| `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` |
| `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` |
@@ -713,7 +703,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/api/routes/bug_reports.py:BugReportCreate`,
`app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_bug_reports.py`
anonimo può inserire segnalazioni senza limite.
#### GET /bug-reports/mine
@@ -833,7 +822,6 @@ dei parametri operativi in
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
*Codice:* `app/api/routes/admin.py:list_users` ·
*Test:* `tests/unit/test_admin.py`
in una sola risposta, senza paginazione né limite.
#### GET /admin/users/{user_id}/privkey
@@ -1031,7 +1019,7 @@ dei parametri operativi in
| `already_betting` | 400 | `app/bets/service.py:place_bet` | — | l'utente ha già una riga di partecipazione nel round | sì |
| `round_full` | 400 | `app/bets/service.py:place_bet` | `max_participants` | il round ha già 400 partecipanti (in qualunque stato) | sì |
| `insufficient_balance` | 400 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/wallet/psbt_builder.py:select_utxos` | `required_sats` (assente se sollevato da `select_utxos`) | le monete confermate e non spese non coprono l'importo | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` *da implementare (B-96)* | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `balance_pending_confirmation` | 400 | `app/withdrawals/service.py:request_withdrawal`; anche `app/bets/service.py:place_bet` | `pending_sats` | l'importo sarebbe coperto dal saldo comprensivo del non confermato, ma non da quello confermato | sì |
| `too_many_inputs` | 400 | `app/wallet/psbt_builder.py:select_utxos` | `max_inputs` | servirebbero più di 50 monete in ingresso | sì |
| `balance_leaves_no_change` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` (solo scommesse) | `required_extra_sats` | dopo la scommessa il resto sarebbe sotto il limite di polvere (294 sat) | sì |
| `amount_below_network_fee` | 400 | `app/wallet/psbt_builder.py:build_signed_transaction` | — | l'importo non copre la commissione di rete | sì |
+2 -5
View File
@@ -681,9 +681,8 @@ flowchart LR
`app/withdrawals/service.py:_release_failed_withdrawal`;
- transazione confermata — `app/tx/confirmation.py:poll_once`;
- riga promossa o abbandonata dal riconciliatore —
`app/tx/reconcile.py:reconcile_once`. Oggi il riconciliatore pubblica per
**ogni** riga che ricontrolla, anche quando non cambia nulla; la
pubblicazione solo sui cambiamenti è in B-96;
`app/tx/reconcile.py:reconcile_once`. Il riconciliatore pubblica solo
per le righe che ha cambiato ([TX-27](transazioni-e-riconciliazione.md#riconciliazione));
- deposito accreditato, UTXO segnata come spesa fuori piattaforma, UTXO
ripristinata — `app/deposits/service.py:credit_confirmed_utxos`,
`app/deposits/service.py:mark_utxos_spent_externally`,
@@ -691,8 +690,6 @@ flowchart LR
- nuovo header ricevuto — `app/electrum/listener.py:ElectrumListener._consume_headers`.
*Test:* `tests/unit/test_bets.py` (verifica la notifica di una scommessa)
*Stato:* da implementare (B-96) · *Oggi:* il riconciliatore pubblica una
notifica per ogni riga ricontrollata, anche senza cambiamenti.
## Lock per utente
+4 -17
View File
@@ -44,9 +44,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`, `app/main.py:lifespan`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/electrum/listener.py:ElectrumListener._record_reorg`
*Stato:* da implementare (B-96), per uno degli eventi
nuovi · *Oggi:* gli eventi osservativi sono i primi quattro,
`chain_reorg_detected` e `rbf_bump_failed`.
- **[AUD-04]** Il `payload` è un dizionario serializzato in JSON nella colonna
`payload_json`. `created_at` è impostato dall'applicazione, non dal
@@ -114,10 +111,7 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
`app/tx/broadcast.py:RbfBumper._tick`,
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
*Test:* `tests/unit/test_scheduler.py` (solo `payout_failed`)
*Stato:* da implementare (B-96), per l'ultima lettura ·
*Oggi:* le letture sono quelle di `payout_failed`, di
`draw_baseline_tip_unknown` e di `rbf_bump_failed`.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_state_consistency.py`
## Catalogo degli eventi
@@ -275,7 +269,6 @@ flowchart LR
sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la
riga.
*Codice:* `app/api/routes/users.py:change_password` · *Test:* —
alcun evento.
- **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato
cronologia sulla catena per almeno uno dei 20 indici di derivazione
@@ -386,9 +379,7 @@ flowchart LR
correnti, secondi trascorsi dalla scadenza. `user_id` no, `round_id` sì.
Transazione propria ([AUD-03]). La riga più recente del round è l'orologio
della ripetizione successiva, 1800 s dopo ([AUD-09]).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'attesa in `closing` non scrive
alcun evento.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` · *Test:* `tests/unit/test_state_consistency.py`
- **[AUD-37]** `draw_committed` — il round è entrato in `drawing`: elenco dei
partecipanti e altezza del blocco di estrazione sono fissati
@@ -536,9 +527,7 @@ flowchart LR
([AUD-21]) o `payout_sent` ([AUD-29]) — con lo stesso payload
([TX-16](transazioni-e-riconciliazione.md#riconciliazione)).
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* nessun test verifica la riga di audit.
*Stato:* da implementare (B-96), per il secondo evento · *Oggi:* si scrive
solo `pending_tx_recovered`.
*Test:* `tests/unit/test_state_consistency.py`
- **[AUD-31]** `pending_tx_abandoned` — una transazione `building` o `pending`
è stata dichiarata persa **con una prova**
@@ -626,9 +615,7 @@ flowchart LR
`app/rounds/scheduler.py:RoundScheduler._tick`,
`app/rounds/confirmation.py:_on_payout_confirmed`,
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
*Stato:* da implementare (B-92), per user_registered
dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non
producono alcuna riga.
*Stato:* da implementare (B-92), per `user_registered`
## Log applicativo
+1 -5
View File
@@ -108,11 +108,7 @@ Le difese sono cinque.
indirizzo non termina la sessione
([DEP-16](registrazione-e-depositi.md#deposito)).
*Codice:* `app/electrum/listener.py:ElectrumListener._run_once`, `app/electrum/client.py:ElectrumClient.wait_closed`, `app/electrum/listener.py:ElectrumListener._consume_scripthash` ·
*Test:* `tests/unit/test_electrum_client.py`
*Stato:* da implementare (B-96), per gli errori di aggiornamento di un
utente · *Oggi:* qualunque eccezione in `refresh_user` chiamato da
`_consume_scripthash` (ad esempio la collisione sull'outpoint di due
aggiornamenti concorrenti) termina la sessione.
*Test:* `tests/unit/test_electrum_client.py`, `tests/unit/test_state_consistency.py`
- **[ELC-07]** Una connessione viene resa disponibile al resto del sistema
**solo dopo** che il primo header è stato applicato: se il client c'è,
l'altezza della catena è nota. Se il primo header è inutilizzabile (privo
+1 -5
View File
@@ -535,11 +535,7 @@ restano nella tabella come storico.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`,
`tests/unit/test_reconcile.py`
*Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo
`payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e
`bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo
successivo).
`tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
- **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi,
`pool_address`, derivato all'apertura da `m/84'/746'/0'/2/<id>` e scritto
+3 -1
View File
@@ -492,7 +492,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
- **[OPS-14]**
**Sintomi.** `GET /rounds/current` riporta `status: "closing"` a lungo, con
`has_pending_bets` vero; nel log compare
`has_pending_bets` vero e `closing_waiting_since` valorizzato; dopo 30 minuti oltre la
scadenza il registro di audit riceve `closing_stalled` (con i txid ancora in volo), ripetuto
ogni 30 minuti ([TX-29](transazioni-e-riconciliazione.md#riconciliazione)); nel log compare
`round N: N bet(s) still in flight at close time, waiting` o nulla (il
controllo principale non scrive). In "Transazioni pendenti" c'è almeno una
riga `bet` di quel round in `building` o `pending`.
+4 -15
View File
@@ -123,7 +123,6 @@ il freno sui tentativi di login falliti è
prima di qualunque verifica.
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
*Test:* `tests/unit/test_auth.py`
del login non hanno limiti di lunghezza.
- ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza):
il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di
specifico del dominio oltre a ciò che SEC-30 già dice.
@@ -144,9 +143,7 @@ il freno sui tentativi di login falliti è
login. Questo requisito è il proprietario del flusso; [sicurezza.md](sicurezza.md)
e [api.md](api.md) vi rimandano.
*Codice:* `app/api/routes/users.py:change_password` ·
*Test:* `tests/unit/test_users.py`
`password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui
tentativi, nessun evento di audit.
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
L'unica strada è il reset dall'operatore
(`POST /admin/users/{id}/reset-password`), che imposta una password casuale
@@ -208,8 +205,7 @@ flowchart TB
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
```
*Comportamento atteso.* Gli aggiornamenti dello stesso utente non sono
ancora in fila ([DEP-15], B-96).
Gli aggiornamenti dello stesso utente sono messi in fila ([DEP-15]).
- **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di
polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non
@@ -328,9 +324,7 @@ ancora in fila ([DEP-15], B-96).
prelievi ([scommesse-e-prelievi.md](scommesse-e-prelievi.md)), così una
corroborazione in corso non ritarda una scommessa. Utenti diversi si
aggiornano in parallelo come oggi.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* due aggiornamenti dello stesso
utente possono girare insieme e tentare di accreditare la stessa moneta.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user` · *Test:* `tests/unit/test_state_consistency.py`
- **[DEP-16]** Un errore durante l'aggiornamento di un utente non chiude la
connessione Electrum: viene registrato nei log e l'utente viene ripreso
all'aggiornamento successivo. In particolare una violazione dell'unicità
@@ -338,9 +332,7 @@ ancora in fila ([DEP-15], B-96).
si annulla la transazione del database di quell'accredito e si prosegue.
Fa eccezione solo la caduta della connessione stessa, che termina la
sessione come in [ELC-06](connessione-electrum.md#3-caduta-rilevata).
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'eccezione risale da
`refresh_user` a `_consume_scripthash` e termina la sessione Electrum.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/deposits/service.py:credit_confirmed_utxos` · *Test:* `tests/unit/test_state_consistency.py`
## Il saldo mostrato
@@ -403,6 +395,3 @@ corso (`has_pending`).
la scommessa [scommesse-e-prelievi.md](scommesse-e-prelievi.md), B-96).
*Codice:* `app/withdrawals/service.py:request_withdrawal`, `app/bets/service.py:place_bet` ·
*Test:* `tests/unit/test_withdrawals.py`
*Stato:* da implementare (B-96), per la scommessa · *Oggi:* solo il
prelievo distingue i due casi; una scommessa coperta solo dal saldo
mostrato riceve `insufficient_balance`.
+4 -11
View File
@@ -38,9 +38,7 @@ Prima dei controlli valgono due condizioni di contorno:
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.
*Test:* `tests/unit/test_state_consistency.py`
Una scommessa (`POST /bets`, senza corpo) passa solo se **tutte** queste
condizioni sono vere, verificate nell'ordine della tabella; altrimenti viene
@@ -54,7 +52,7 @@ cifra. Il **costo** è quello del round ([BET-21]):
| **[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. | `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-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)). |
| **[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` |
@@ -241,9 +239,7 @@ esterno scelto dall'utente.
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.
*Test:* `tests/unit/test_state_consistency.py`
Le condizioni sono verificate nell'ordine della tabella; un rifiuto risponde
HTTP 400 prima che si muova qualunque cifra:
@@ -290,10 +286,7 @@ solo se la rete rifiuta esplicitamente, RBF se non conferma in tempo.
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.
*Test:* `tests/unit/test_state_consistency.py`
- **[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
+5 -17
View File
@@ -323,9 +323,7 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
[REG-12](registrazione-e-depositi.md#sessione-e-password).
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
*Test:* `tests/unit/test_users.py`
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
i campi del login e del cambio password non hanno limite di lunghezza.
*Test:* `tests/unit/test_users.py`, `tests/unit/test_abuse_limits.py`
- ~~[SEC-18]~~ Assorbito da [REG-01](registrazione-e-depositi.md#registrazione)
e [REG-02](registrazione-e-depositi.md#registrazione): formato dello
@@ -373,8 +371,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
`app/api/routes/admin.py:reset_user_password` ·
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
l'header assente risponde 401 con il corpo non strutturato di FastAPI
`{"detail": "Not authenticated"}`.
- **[SEC-23]** Gli endpoint raggiungibili anche da non autenticati (ad esempio
`GET /rounds/current`, `POST /bug-reports`) usano l'utente solo per
@@ -396,7 +392,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
*Codice:* `app/api/routes/admin.py:require_admin` ·
*Test:* `tests/unit/test_admin.py`
non strutturato `{"detail": "invalid admin token"}`.
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
singolo operatore, e l'audit log registra *cosa* è cambiato ma non *chi*
@@ -491,7 +486,6 @@ ogni riavvio e non funzionerebbero con più worker.
*Codice:* `app/api/routes/users.py:change_password`,
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
*Test:* `tests/unit/test_abuse_limits.py`
la password attuale si può provare senza limiti.
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
([SEC-35]): al massimo 10 segnalazioni per IP ogni 3600s (finestra mobile,
@@ -503,7 +497,6 @@ ogni riavvio e non funzionerebbero con più worker.
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
`app/auth/rate_limit.py:RollingQuota` ·
*Test:* `tests/unit/test_abuse_limits.py`
inserire segnalazioni senza limite, fino a riempire il database.
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
rimosse (al più ogni 60s, e alla lettura), e comunque non si superano 50.000
@@ -518,9 +511,7 @@ ogni riavvio e non funzionerebbero con più worker.
[API-16](api.md#contratto-degli-errori); il browser ripiega sul polling).
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
`app/api/routes/rounds.py:round_stream` ·
*Test:* `tests/unit/test_round_events.py`
*Oggi:* il 503 ha il corpo non strutturato
`{"detail": "too many concurrent update streams"}`.
*Test:* `tests/unit/test_round_events.py`, `tests/unit/test_abuse_limits.py`
- **[SEC-34]** **Non** hanno limiti di frequenza: scommesse, prelievi,
endpoint admin (tentativi sul token compresi), QR, `GET /rounds/current` e
@@ -678,10 +669,8 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
validazione e framework compresi — ha il formato strutturato con un codice
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
così una password troppo lunga non torna indietro in chiaro.
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
`input` il valore ricevuto.
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py` ·
*Test:* `tests/unit/test_abuse_limits.py`
Il pannello `/admin` non è collegato da `/` (né viceversa), ma è una scelta
di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
@@ -753,8 +742,7 @@ sicurezza.
il quorum prima del pagamento
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round
del backup già pagato risulta impagabile invece di essere pagato due volte
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
database.
con il denaro dei round successivi.
- **Chiave sbagliata.** Una master key che non corrisponde al database
ripristinato ferma l'avvio ([SEC-49]).
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
+4 -4
View File
@@ -244,7 +244,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API |
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | *da registrare* | `test_state_consistency.py` | TX, BET, WDR, DEP |
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT |
| B-98 | La selezione delle monete si ferma alla prima che copre l'importo e rifiuta una scommessa pagabile | Media | *da registrare* | `test_psbt_builder.py` | BET, WDR |
| B-99 | Un server Electrum di Bitcoin supera ogni controllo sugli header (PLM ne condivide genesis e `powLimit`): il genesis non veniva verificato | Media | *da registrare* | `test_electrum_client.py` | ELC |
@@ -254,8 +254,8 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93 e B-96 (0 critici,
0 alti, 3 medi), da implementare nell'ordine di
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-96, B-98, B-99, B-100.
- **Aperti:** dalla revisione del 2026-09-28, B-92 e B-93 (0 critici,
0 alti, 2 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-101`.
+6 -18
View File
@@ -328,11 +328,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
corrente vale [TX-19]. Da lì la conferma segue la via normale. Una riga già
`pending` presente in rete non viene toccata.
*Codice:* `app/tx/reconcile.py:_promote` ·
*Test:* `tests/unit/test_reconcile.py`
*Stato:* da implementare (B-96) · *Oggi:* si scrive solo
`pending_tx_recovered`: una scommessa o un pagamento recuperati non hanno
mai `bet_placed` o `payout_sent` (e un prelievo recuperato non ha
`withdrawal_sent`).
*Test:* `tests/unit/test_reconcile.py`, `tests/unit/test_state_consistency.py`
- **[TX-17]** **Abbandono solo con prova.** Una riga viene abbandonata solo
se vale una di queste due prove:
1. un suo ingresso risulta **speso in un blocco** da una transazione che
@@ -370,9 +366,7 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
cui ha cambiato qualcosa (promozione, abbandono, riallineamento del txid),
non per ogni riga esaminata.
*Codice:* `app/tx/reconcile.py:reconcile_once` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* pubblica una notifica per ogni
riga che ha interrogato, anche quando non cambia nulla.
*Test:* `tests/unit/test_state_consistency.py`
- **[TX-25]** Una scommessa in volo esce dall'attesa del round in `closing`
([ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#2--chiusura)) in
due soli modi: confermata ([TX-07]) o abbandonata con prova ([TX-17]). Non
@@ -383,16 +377,14 @@ riserva di [TX-11] serve proprio a dargli un'uscita da ridurre.
- **[TX-29]** L'attesa in `closing` senza tempo massimo ([TX-25]) è
**visibile**: quando un round resta in `closing` oltre la scadenza per più
di `CLOSING_STALL_THRESHOLD_SECONDS` (1800 s), lo scheduler scrive un
evento `closing_stalled` ([audit-log.md](audit-log.md) (B-96)) con il round,
evento `closing_stalled` ([AUD-46](audit-log.md)) con il round,
il numero di scommesse ancora in volo e i loro txid, e lo ripete ogni 1800 s
finché l'attesa dura; `GET /rounds/current` espone da quando il round attende
(`closing_waiting_since`). L'operatore decide con la procedura di
[operativita.md](operativita.md) (B-96); il sistema non esclude da solo una
[OPS-14](operativita.md#round-bloccato-in-closing); il sistema non esclude da solo una
scommessa in volo dall'estrazione.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._tick` ·
*Test:* —
*Stato:* da implementare (B-96) · *Oggi:* l'attesa non produce alcun
segnale oltre al round che resta in `closing`.
*Test:* `tests/unit/test_state_consistency.py`
## Pagamenti del round: riprovare senza pagare due volte
@@ -414,11 +406,7 @@ queste ultime due, un nuovo tentativo potrebbe pagare il vincitore due volte.
`paying_out` senza transazione né importi di un pagamento che non esiste, e
lo scheduler ricostruisce il pagamento al tentativo successivo.
*Codice:* `app/tx/reconcile.py:_abandon`, `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` ·
*Test:* — (`tests/unit/test_scheduler.py` verifica i nuovi tentativi di un
round in `paying_out`, non la sequenza abbandono → ricostruzione)
*Stato:* da implementare (B-96) · *Oggi:* azzera solo `payout_txid`;
`winner_amount_sats` e `fee_amount_sats` restano quelli del pagamento
abbandonato.
*Test:* `tests/unit/test_state_consistency.py`
## Dove guardare
+257
View File
@@ -0,0 +1,257 @@
"""B-96: the smaller state inconsistencies — WDR-15, BET-07 (pending-covered bet), TX-16, TX-18,
TX-27, TX-29/AUD-46, DEP-15/DEP-16 and BET-02/WDR-01."""
import asyncio
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.bets.service import BetError, place_bet
from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
from app.rounds.events import broadcaster
from app.rounds.scheduler import CLOSING_STALL_THRESHOLD_SECONDS, RoundScheduler
from app.tx.reconcile import reconcile_once
from app.withdrawals.service import WithdrawalError, request_withdrawal
from tests.unit.fake_network import FakeNetwork
from tests.unit.test_reconcile import KnownTxClient, _seed_bet
from tests.unit.test_reconcile import session_factory as reconcile_session_factory # noqa: F401 (fixture)
from tests.unit.test_scheduler import FakeListener, _seed_closing_round
from tests.unit.test_scheduler import session_factory as scheduler_session_factory # noqa: F401 (fixture)
from tests.unit.test_withdrawals import (
BET_AMOUNT_SATS,
EXTERNAL_ADDRESS,
FakeElectrumClient,
_make_funded_user,
)
from tests.unit.test_withdrawals import session_factory as wallet_session_factory # noqa: F401 (fixture)
# --- WDR-15 and the pending-covered bet ---------------------------------------------
async def test_a_reduced_full_balance_withdrawal_is_checked_against_the_minimum(wallet_session_factory):
"""WDR-15: moving the minimum from a one-coin balance leaves a dust limit behind, which puts
the amount that actually goes out under the minimum."""
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 50, BET_AMOUNT_SATS)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError) as excinfo:
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
assert excinfo.value.code == "amount_below_minimum"
assert excinfo.value.params == {"minimum_sats": BET_AMOUNT_SATS}
assert client.broadcasted == []
async def test_a_bet_covered_only_by_unconfirmed_change_says_wait_not_insufficient(wallet_session_factory):
"""BET-07 / DEP-13: the balance on screen covers the bet, the confirmed one does not."""
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 51, 3_000_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
await request_withdrawal(session, FakeElectrumClient(), user, EXTERNAL_ADDRESS, 1_200_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
with pytest.raises(BetError) as excinfo:
await place_bet(session, FakeElectrumClient(), user)
assert excinfo.value.code == "balance_pending_confirmation"
assert excinfo.value.params["pending_sats"] > 0
async def test_a_bet_with_no_money_at_all_is_still_insufficient(wallet_session_factory):
session_factory = wallet_session_factory
user_id = await _make_funded_user(session_factory, 52, 100_000_000)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError) as excinfo:
await place_bet(session, FakeElectrumClient(), user)
assert excinfo.value.code == "insufficient_balance"
# --- TX-16 / TX-18 / TX-27 -----------------------------------------------------------
async def _events(session_factory):
async with session_factory() as session:
return [e.event_type for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
async def test_a_recovered_bet_gets_the_event_its_send_would_have_written(reconcile_session_factory):
"""TX-16 / AUD-30: pending_tx_recovered alone left a recovered bet out of the audit trail."""
session_factory = reconcile_session_factory
await _seed_bet(session_factory, pending_status="building", participant_status="building", age_seconds=300)
await reconcile_once(session_factory, KnownTxClient("betxid"))
assert sorted(await _events(session_factory)) == ["bet_placed", "pending_tx_recovered"]
async with session_factory() as session:
placed = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "bet_placed"))).one()
assert json.loads(placed.payload_json) == {"txid": "betxid", "amount_sats": 999_000_000}
assert placed.user_id is not None and placed.round_id == 1
async def test_a_recovered_withdrawal_and_payout_get_theirs_too(reconcile_session_factory):
from app.tx.reconcile import _promote
session_factory = reconcile_session_factory
async with session_factory() as session:
withdrawal = Withdrawal(
user_id=1, external_address=EXTERNAL_ADDRESS, amount_requested_sats=5, amount_sent_sats=4, txid="w", status="building"
)
round_ = Round(status="paying_out", winner_user_id=7, winner_amount_sats=700, fee_amount_sats=300)
session.add_all([withdrawal, round_])
await session.flush()
w_row = PendingTransaction(kind="withdrawal", withdrawal_id=withdrawal.id, user_id=1, current_txid="w", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
p_row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="building")
session.add_all([w_row, p_row])
await session.flush()
await _promote(session, w_row)
await _promote(session, p_row)
await session.commit()
rows = {e.event_type: e for e in (await session.scalars(select(AuditLog))).all()}
assert json.loads(rows["withdrawal_sent"].payload_json) == {
"txid": "w", "amount_sent_sats": 4, "external_address": EXTERNAL_ADDRESS,
}
assert json.loads(rows["payout_sent"].payload_json) == {"txid": "p", "winner_sats": 700, "commission_sats": 300}
assert rows["payout_sent"].user_id == 7
async def test_an_abandoned_payout_leaves_no_amounts_behind(reconcile_session_factory):
"""TX-18 / DAT-22."""
from app.tx.reconcile import _abandon
session_factory = reconcile_session_factory
async with session_factory() as session:
round_ = Round(
status="paying_out", payout_txid="p", winner_amount_sats=700, fee_amount_sats=300, bump_reserve_sats=50
)
session.add(round_)
await session.flush()
row = PendingTransaction(kind="payout", round_id=round_.id, current_txid="p", fee_rate_sat_vb=1, raw_tx_hex="", status="pending")
session.add(row)
await session.flush()
await _abandon(session, row, "no server knows it")
await session.commit()
await session.refresh(round_)
assert (round_.payout_txid, round_.winner_amount_sats, round_.fee_amount_sats, round_.bump_reserve_sats) == (
None, None, None, None,
)
async def test_the_reconciler_only_pings_the_dashboards_for_rows_it_changed(reconcile_session_factory, monkeypatch):
"""TX-27: a row found present and already `pending` is examined, not changed."""
session_factory = reconcile_session_factory
await _seed_bet(session_factory, pending_status="pending", participant_status="broadcast", age_seconds=30 * 3600)
pings = []
monkeypatch.setattr(broadcaster, "publish", lambda: pings.append(1))
assert await reconcile_once(session_factory, KnownTxClient("betxid")) == 0
assert pings == []
# --- TX-29 / AUD-46 ------------------------------------------------------------------
async def _stalled_rows(session_factory):
async with session_factory() as session:
return list(
(await session.scalars(select(AuditLog).where(AuditLog.event_type == "closing_stalled").order_by(AuditLog.id))).all()
)
async def test_closing_stalled_is_written_past_the_threshold_and_repeats_every_30_minutes(scheduler_session_factory):
session_factory = scheduler_session_factory
round_id = await _seed_closing_round(session_factory, n=2)
async with session_factory() as session:
for participant in (await session.scalars(select(RoundParticipant))).all():
participant.status = "broadcast"
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
now = datetime.now(timezone.utc)
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS - 60))
assert await _stalled_rows(session_factory) == [] # not long enough yet
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 60))
(row,) = await _stalled_rows(session_factory)
payload = json.loads(row.payload_json)
assert payload["in_flight"] == 2 and len(payload["txids"]) == 2 and payload["seconds_past_deadline"] >= 1860
assert row.round_id == round_id and row.user_id is None
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 120))
assert len(await _stalled_rows(session_factory)) == 1 # the last row is the clock: not again yet
async with session_factory() as session:
last = await session.get(AuditLog, row.id)
last.created_at = last.created_at - timedelta(seconds=CLOSING_STALL_THRESHOLD_SECONDS + 1)
await session.commit()
await scheduler._note_closing_stall(round_id, now - timedelta(seconds=2 * CLOSING_STALL_THRESHOLD_SECONDS))
assert len(await _stalled_rows(session_factory)) == 2
# --- DEP-15 / DEP-16 / BET-02 / WDR-01 ------------------------------------------------
async def test_refreshes_of_one_user_run_one_at_a_time_and_other_users_in_parallel(scheduler_session_factory):
from app.electrum.listener import ElectrumListener
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
running, peak, order = {}, {}, []
async def fake_locked(user_id, scripthash):
running[user_id] = running.get(user_id, 0) + 1
peak[user_id] = max(peak.get(user_id, 0), running[user_id])
order.append((user_id, scripthash))
await asyncio.sleep(0.01)
running[user_id] -= 1
listener._refresh_user_locked = fake_locked
await asyncio.gather(
listener.refresh_user(1, "a"), listener.refresh_user(1, "b"), listener.refresh_user(2, "c")
)
assert peak == {1: 1, 2: 1}
assert [s for u, s in order if u == 1] == ["a", "b"] # in order of arrival
async def test_one_users_failed_refresh_does_not_end_the_session(scheduler_session_factory):
from app.electrum.listener import ElectrumListener
listener = ElectrumListener(lambda endpoint: None, scheduler_session_factory)
listener._scripthash_to_user = {"s1": 1, "s2": 2}
calls = []
async def fake_refresh(user_id, scripthash):
calls.append(user_id)
if user_id == 1:
raise RuntimeError("duplicate txid/vout")
listener.refresh_user = fake_refresh
queue = asyncio.Queue()
for item in (("s1", None), ("s2", None)):
queue.put_nowait(item)
task = asyncio.create_task(listener._consume_scripthash(queue))
await asyncio.sleep(0.05)
assert calls == [1, 2] and not task.done()
task.cancel()
async def test_the_connection_is_read_after_the_user_lock_is_taken():
"""BET-02 / WDR-01: a request queued on the lock across a reconnection must use the
connection that exists when it gets the lock, not the one it saw on arrival."""
from contextlib import asynccontextmanager
from app.api.routes import bets as bets_route
listener = SimpleNamespace(client="old")
@asynccontextmanager
async def acquire(_user_id):
listener.client = None # the connection dropped while this request waited
yield
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(electrum_listener=listener, user_locks=SimpleNamespace(acquire=acquire))))
with pytest.raises(Exception) as excinfo:
await bets_route.create_bet(request, SimpleNamespace(), SimpleNamespace(id=1), None)
assert excinfo.value.detail["code"] == "network_unavailable"