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:
1 parent
24e5fc3d8b
commit
9520c80dcf
23 files changed
+445
-151
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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ì |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user