|
|
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
|
|
|
|
|
|
|
|
|
|
|
## Project status
|
|
|
|
## Project status
|
|
|
|
|
|
|
|
|
|
|
|
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (54 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), and a static test UI for the user-facing flow (`/`).
|
|
|
|
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (76 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), a static test UI for the user-facing flow (`/`), a pending-inclusive balance display (see "Balance display" below), and a Server-Sent Events push channel layered on top of the original polling (see "Real-time updates" below).
|
|
|
|
|
|
|
|
|
|
|
|
Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready.
|
|
|
|
Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready.
|
|
|
|
|
|
|
|
|
|
|
@@ -81,6 +81,25 @@ Mainnet:
|
|
|
|
- Block time: 120s
|
|
|
|
- Block time: 120s
|
|
|
|
- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.cs`
|
|
|
|
- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.cs`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Balance display
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`place_bet`/`request_withdrawal` (`app/bets/service.py`, `app/withdrawals/service.py`) select whole UTXOs to cover the amount (`select_utxos`, largest-first) and mark every selected UTXO `spent_txid` immediately at broadcast time — well before the tx has any confirmations. `User.cached_balance_sats` (`recompute_balance`, `app/wallet/balance.py`) only sums confirmed, unspent UTXOs, so right after a bet/withdrawal it understates the user's real balance by the entire unconfirmed change amount, which is often far larger than the amount actually moving.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without touching what's actually spendable: it decodes the raw tx of every in-flight (`status="pending"`) bet/withdrawal `PendingTransaction` belonging to the user and sums whichever outputs pay back to the user's own address, adding that to `cached_balance_sats`. `GET /users/me` returns both `balance_sats` (confirmed-only — still what withdrawal-max and internal spend logic use, since only confirmed UTXOs are actually spendable) and `pending_balance_sats` + `has_pending` (what the frontend displays, colored green when settled and amber while `has_pending` is true).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Real-time updates (SSE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`GET /rounds/stream` (`app/api/routes/rounds.py`) is a Server-Sent Events channel layered *on top of* the original polling loops in `app/static/index.html`/`admin.html` — polling is the fallback, not replaced, so a blocked/dropped SSE connection just degrades to the pre-existing behavior. The channel carries no payload and needs no auth: it's purely a "something changed, go refetch" ping; personalization (e.g. `user_played` below) still lives entirely in the normal per-user REST endpoints.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
`app/rounds/events.py`'s `RoundEventBroadcaster` (module-level singleton `broadcaster`) is a simple in-process pub/sub — one `asyncio.Queue` (maxsize 1, so redundant notifications coalesce) per connected SSE client. `broadcaster.publish()` is called from every point that changes something a dashboard would want to know about: a new round opening (`rounds/service.py`), every round status transition (`rounds/scheduler.py`: closing/drawing/paying_out/closed), a bet or withdrawal broadcast (`bets/service.py`, `withdrawals/service.py`), any pending tx confirming — bet/withdrawal/payout (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new block tip arriving (`electrum/listener.py` — the exact moment the "drawing" phase is waiting on).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Deliberate scope decisions, not oversights:
|
|
|
|
|
|
|
|
- **Single-process only, no cross-worker fan-out.** Fine for the current deployment (one uvicorn process, see `docker-compose.yml`). A multi-worker/multi-container deployment would need a shared channel (e.g. Redis pub/sub) instead — don't add that speculatively before it's actually needed.
|
|
|
|
|
|
|
|
- **Generic broadcast, not a per-user channel.** Every connected client refetches on every event, even ones irrelevant to them. Acceptable at the expected scale (~100 concurrent users); a targeted per-user channel would need auth on the SSE endpoint and server-side knowledge of who's affected by each event — real engineering work, only worth it well past current expected concurrency.
|
|
|
|
|
|
|
|
- `MAX_SUBSCRIBERS` (default 500, `app/rounds/events.py`) is a defensive cap only — past it, `GET /rounds/stream` returns 503 instead of opening a stream, and the client's `EventSource` just falls back to polling. Not a substitute for the app-wide "no rate limiting anywhere" gap (see Known gaps).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Frontend: both `index.html` and `admin.html` open an `EventSource('/rounds/stream')` and, on an `update` message *or* on `open` (which fires on the initial connection and every automatic reconnect), immediately re-run the same refresh calls polling would eventually do — this matters most right after a dropped connection reconnects, closing most of the "missed while disconnected" gap.
|
|
|
|
|
|
|
|
|
|
|
|
## MVP business parameters
|
|
|
|
## MVP business parameters
|
|
|
|
|
|
|
|
|
|
|
|
- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant.
|
|
|
|
- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant.
|
|
|
@@ -99,7 +118,7 @@ The flow is organized into 5 phases, each a subgraph in [flowchart.mmd](flowchar
|
|
|
|
- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals.
|
|
|
|
- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals.
|
|
|
|
- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic.
|
|
|
|
- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic.
|
|
|
|
- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast.
|
|
|
|
- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast.
|
|
|
|
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a "drawing" animation on every user's dashboard for at least `draw_animation_seconds` (admin-configurable, default 20s) once the round starts closing — purely cosmetic, decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set; see `GET /rounds/current`'s `winner_user_id`/`winner_amount_sats` and `app/static/index.html`'s reveal logic.
|
|
|
|
- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a generic "drawing" status box (phase label, e.g. "Pagamento al vincitore in corso…") to **every** viewer on every dashboard for the whole closing/drawing/paying_out phase — this one is purely cosmetic status text, driven directly by `status`, no gating. Independently and *additively* (not instead of it), a personalized "Hai vinto!/Non hai vinto" box appears only for users where `GET /rounds/current`'s `user_played` field is true (computed via `app/auth/dependencies.py:get_optional_user`, since this endpoint is reachable logged-out too) — everyone else has nothing to reveal and never sees it. That reveal is additionally delayed by at least `draw_animation_seconds` (admin-configurable, default 20s) for cosmetic suspense, anchored to the round's server-provided `closes_at` timestamp rather than a client-side "first seen" time (so reloading the page can't reset the countdown), and decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set. Once revealed, the result is persisted in the browser's `localStorage` (`plm_persisted_result`) so it survives a page refresh even after the round moves past `paying_out` into `closed` — at which point `get_active_round` stops returning that round at all and `winner_user_id` disappears from `GET /rounds/current` entirely. `GET /users/me/last-round-result` (`app/api/routes/users.py`) is a durable, DB-backed backstop for a user who reloads on a browser/device that missed the live reveal window completely: it looks up the most recent *closed* round the user has a `RoundParticipant` row in. See `app/static/index.html`'s `refreshRound`/`checkLastRoundResult` for the full reveal logic.
|
|
|
|
- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern.
|
|
|
|
- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern.
|
|
|
|
|
|
|
|
|
|
|
|
PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address.
|
|
|
|
PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address.
|
|
|
@@ -113,7 +132,7 @@ So worst case (last bet confirms right at the deadline) is ~3 block times end-to
|
|
|
|
|
|
|
|
|
|
|
|
## Admin dashboard and test UI
|
|
|
|
## Admin dashboard and test UI
|
|
|
|
|
|
|
|
|
|
|
|
Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework:
|
|
|
|
Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler):
|
|
|
|
|
|
|
|
|
|
|
|
- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu.
|
|
|
|
- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu.
|
|
|
|
- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL.
|
|
|
|
- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL.
|
|
|
@@ -139,7 +158,7 @@ Not blockers for reading the code, but must be addressed before this is producti
|
|
|
|
- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError` and needs manual operator intervention. Documented in `tx/broadcast.py`.
|
|
|
|
- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError` and needs manual operator intervention. Documented in `tx/broadcast.py`.
|
|
|
|
- **Payout retry**: if `_trigger_payout` fails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck in `paying_out` with no automatic retry.
|
|
|
|
- **Payout retry**: if `_trigger_payout` fails (e.g. insufficient pool UTXOs, Electrum disconnected), it just logs and returns — the round stays stuck in `paying_out` with no automatic retry.
|
|
|
|
- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM as of this commit.
|
|
|
|
- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM as of this commit.
|
|
|
|
- **No user-facing history endpoints** (list my own bets / withdrawals / past rounds) — a user still only has `/users/me` (balance). The admin side now has this (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's no equivalent scoped to "my own history" for a logged-in user.
|
|
|
|
- **No general user-facing history endpoints** (list my own bets / withdrawals / past rounds) — `GET /users/me/last-round-result` covers exactly one case (the outcome of the most recent *closed* round the user played in, as a reveal-persistence backstop; see DRAW above), not a real history. The admin side has more (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's still no "my own full history" equivalent for a logged-in user.
|
|
|
|
- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity or audit trail of *who* changed config (the `audit_log` table records *what* changed, not which operator did it). This token now gates a lot more than config (user list, private key export, round/audit history), so its blast radius if leaked is correspondingly larger.
|
|
|
|
- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity or audit trail of *who* changed config (the `audit_log` table records *what* changed, not which operator did it). This token now gates a lot more than config (user list, private key export, round/audit history), so its blast radius if leaked is correspondingly larger.
|
|
|
|
- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin).
|
|
|
|
- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin).
|
|
|
|
- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite.
|
|
|
|
- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite.
|
|
|
|