28 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 f21ecbd4ee Add a cooldown between rounds (ROUND_COOLDOWN_SECONDS)
open_new_round_if_needed now withholds opening the next round until
ROUND_COOLDOWN_SECONDS (default 30) have passed since the previous
round's closed_at, returning None in that window instead of a Round.
Without this, the next round opened within one scheduler tick (~5s) of
the previous payout confirming — not enough time for a player to
notice the round they were in actually resolved.

Callers updated: the scheduler treats None as "nothing to do this
tick", and place_bet raises a "try again shortly" BetError instead of
crashing on a None round.

Not in the original flowchart — a deliberate UX addition on top of it,
documented as such in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:29:59 +02:00
davideandClaude Sonnet 5 abb3418669 Enable the ui-ux-pro-max Claude Code plugin for this project
Used to generate the color palette/typography/UX guidelines behind the
test and admin UI redesign. Checked in so the plugin is enabled for
anyone else working on this repo with Claude Code, not just this
session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:22:35 +02:00
davideandClaude Sonnet 5 784f30ddd7 Add docs/ with separate setup, run, user and admin guides
Four standalone Markdown docs instead of growing CLAUDE.md further:
setup.md (one-time secrets/master-key/migrations), running-the-server.md
(local venv vs Docker+Caddy, dev self-signed vs production domain),
guida-utente.md (dashboard: deposit+QR, bet, withdrawal, round timer/
jackpot) and guida-admin.md (the /admin panel and its API equivalent).
Written in Italian per explicit request, unlike the rest of the
repository's English-only docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:22:03 +02:00
davideandClaude Sonnet 5 6683f197eb Document the Docker + Caddy deployment workflow
Records the docker compose commands (master key bootstrap, up/down,
log tailing) and the SITE_ADDRESS dev-vs-production behavior, plus an
explicit warning: app's restart:unless-stopped means a crash mid-round
auto-restarts into the still-open scheduler-resume gap, so this isn't
unattended-safe yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:15:11 +02:00
davideandClaude Sonnet 5 d35784d574 Add docker-compose stack: app + Caddy reverse proxy with automatic TLS
Caddy's site address comes from SITE_ADDRESS (defaults to "localhost").
Left at that default, Caddy detects it isn't a public hostname and
issues a self-signed cert from its own internal CA — no domain needed
for local/dev testing. Set to a real domain, it gets a genuine Let's
Encrypt certificate automatically instead.

DB, encrypted master key and logs are bind-mounted from ./data/ on the
host (not opaque Docker-managed volumes), so they survive container
restarts/rebuilds and stay reachable for manual inspection/backup
directly from the repo root. ./data/ is gitignored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:15:04 +02:00
davideandClaude Sonnet 5 2dedc283c6 Add Dockerfile for the app
python:3.12-slim, installs the package (pip install .), runs pending
Alembic migrations before starting uvicorn. Migrations/alembic.ini/
scripts are copied in as-is (not part of the installed package) since
alembic and generate_master_key.py run directly against the source
tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 11:14:56 +02:00
davideandClaude Sonnet 5 63bc1d911b Show round timer, players and jackpot in the dashboard
A card above the section menu polls GET /rounds/current every 15s and
ticks a mm:ss countdown to closes_at every second locally, showing
status (aperto/in chiusura/estrazione/pagamento), participant count
and jackpot in PLM. Refreshed immediately after placing a bet, and
timers are cleared on logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:57:39 +02:00
davideandClaude Sonnet 5 7f2d0bcec6 Add a public current-round status endpoint
GET /rounds/current returns the active round's id/status, opened_at/
closes_at (derived from ROUND_DURATION_SECONDS), participant count and
jackpot (participant_count * bet_amount_sats). No active round still
returns bet_amount_sats so clients can render a fixed-entry hint either
way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:57:31 +02:00
davideandClaude Sonnet 5 7e3eec3e4e Turn the user dashboard into a menu-driven layout with a deposit QR
Deposito/Bet/Prelievo are now separate panels behind a top nav instead
of one long scroll of cards, and the Deposito panel renders the
deposit address as a QR code (via GET /qr/{address}) alongside the
existing copy-to-clipboard address box.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:52:06 +02:00
davideandClaude Sonnet 5 dd45ec75c6 Add a QR code endpoint for PLM addresses
GET /qr/{address} renders the address as a PNG QR code (qrcode[pil]),
gated by a bech32-shaped regex since it's otherwise unauthenticated —
the address itself isn't sensitive, but this keeps it from being used
as an arbitrary text-to-QR service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:51:56 +02:00
davideandClaude Sonnet 5 8c659e2be5 Redesign the test/admin UIs and drop the on-page log panel
New visual system from the ui-ux-pro-max skill (gold/purple accents,
Fira Sans + Fira Code, light mode, WCAG AA contrast): card layout,
tabbed login/register, loading states on every button, and toast
feedback instead of a raw request/response JSON dump. The dump is gone
from both pages — diagnosing an error now means reading logs/app.log,
not staring at the page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:46:55 +02:00
davideandClaude Sonnet 5 7e5c372833 Log to a file instead of only stdout
All app and uvicorn logging now goes to logs/app.log (rotating,
10MB x5), and a catch-all exception handler logs full tracebacks there
before returning a generic 500 — so an error is traceable to its cause
without depending on how the process was launched. logs/ is gitignored,
like the other runtime artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:46:47 +02:00
davideandClaude Sonnet 5 ddaca5520e Show and accept amounts in PLM in the test/admin UIs
Balance, withdrawal amount and bet_amount_sats are entered/displayed in
PLM in both static pages; conversion to sats happens client-side right
before the API call, since the backend contract stays sats-based.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:38:26 +02:00
davideandClaude Sonnet 5 a6dbe48457 Serve the admin panel at its own unlinked /admin page
Moves the fee_address/bet_amount_sats config form out of the main test
UI into a dedicated admin.html, served by a GET /admin route
(registered ahead of the StaticFiles mount so it doesn't shadow the
existing GET/PUT /admin/config API). Deliberately not linked from the
test UI in either direction: reachable only by knowing the URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:36:55 +02:00
davideandClaude Sonnet 5 41863691a0 Add admin panel to the test UI
Lets an operator load and update fee_address/bet_amount_sats from a
form (using the X-Admin-Token header) instead of curl/Swagger, with
inline status feedback and the same request/response log as the rest
of the page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:32:05 +02:00
davideandClaude Sonnet 5 f783cfaf80 Update CLAUDE.md with real commands and known gaps
Records the MVP build as code-complete and unit-tested, documents the
real install/run/test commands now that the project is scaffolded, and
lists known gaps (scheduler restart resume, payout retry, RBF fallback,
missing history endpoints, deployment, admin auth, rate limiting) to
address before treating this as production-ready.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:27:12 +02:00
davideandClaude Sonnet 5 ac5ee2ac2c Add static test UI for manual QA
Single-page vanilla HTML/JS frontend (register/login, balance,
place bet, withdraw) served by FastAPI at the same origin so it can
exercise the live API without CORS setup. Manual-testing aid only,
not part of the MVP spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:27:05 +02:00
davideandClaude Sonnet 5 c45bf543c0 Wire up the FastAPI app and master-key bootstrap script
app/main.py assembles the lifespan-managed background tasks (Electrum
listener, round scheduler, confirmation poller, RBF bumper) and mounts
all routers behind a /health check. generate_master_key.py is the
one-time ops script that creates and Fernet-encrypts the server's
master xprv before first launch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:26:42 +02:00
davideandClaude Sonnet 5 01331c1e4c Add admin config and audit log
Bearer-token-gated admin endpoints to read/update the DB-backed
operational config (fee_address, bet_amount_sats) without a redeploy,
plus a lightweight audit log writer for round/payout/config events.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:26:31 +02:00
davideandClaude Sonnet 5 8380b80d12 Add withdrawal flow
Builds and broadcasts a user->external-address PSBT with change back to
the user's own address, fee deducted from the withdrawn amount, and
registers the confirmation handler that marks the withdrawal confirmed.
Shares the per-user lock with bets so a build never races a spend from
the same UTXO set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:26:25 +02:00
davideandClaude Sonnet 5 5ce49d7b88 Add round lifecycle, draw algorithm and scheduler
Periodic scheduler (configurable round duration) that closes a round
only once all broadcast bets confirm, waits for the next block after
closing, draws a winner via block-hash-seeded modulo over participants
ordered by broadcast time, triggers the 70/30 payout, and only opens the
next round once that payout confirms. Draw logic is isolated in
draw.py as a deliberately simple, replaceable component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:26:18 +02:00
davideandClaude Sonnet 5 492fc29eca Add bet flow
Places the fixed-cost bet into the current round: builds and broadcasts
the user->pool PSBT with change back to the user's own address, enforces
at most one active bet per user, and registers the confirmation handler
that marks a bet confirmed and adds the participant to the round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:26:11 +02:00
davideandClaude Sonnet 5 dce532f17e Add deposit crediting from confirmed UTXOs
Credits a user's internal balance once a UTXO on their deposit address
reaches 1 confirmation, keeping utxo_events as the source of truth and
cached_balance_sats as a derived read cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:26:03 +02:00
davideandClaude Sonnet 5 fc2aadbc7e Add transaction broadcast, confirmation polling and RBF fee-bump
Shared per-user locking to serialize bet/withdrawal PSBT builds
(tx/locks.py), a confirmation poller for pending outgoing transactions,
and the timeout->fee-bump->rebroadcast loop used by bets, payouts and
withdrawals alike (tx/broadcast.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:57 +02:00
davideandClaude Sonnet 5 107e592704 Add authentication and user profile endpoint
Argon2 password hashing, JWT session issuing/verification
(auth/security.py), register/login routes, the bearer-token
get_current_user dependency, and GET /users/me for address + balance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:49 +02:00
davideandClaude Sonnet 5 a21e058cdd Add Electrum SPV client and address listener
Minimal Electrum protocol client (client.py) plus a scripthash
subscription listener (listener.py) that watches user deposit addresses
for confirmed UTXOs, with the address->scripthash conversion helper and
a manual smoke-test script against the dev bootstrap server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:38 +02:00
davideandClaude Sonnet 5 f1261584ff Add wallet key derivation and PSBT building
BIP84 derivation of per-user P2WPKH addresses and the pool address from
the encrypted master xprv (app/wallet/hd.py, keystore.py), the PLM
mainnet chain params (plm_network.py), and PSBT construction for bets/
payouts/withdrawals with change-output and fee-estimation logic
(psbt_builder.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:29 +02:00
davideandClaude Sonnet 5 d2db762d96 Scaffold project layout, DB schema and settings
Package skeleton, pyproject/alembic config, env-driven settings
(app/config.py), and the SQLAlchemy models + initial Alembic migration
covering users, UTXO events, rounds/participants, round config,
pending transactions, withdrawals and the audit log.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:25:23 +02:00
90 changed files with 4740 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"ui-ux-pro-max@ui-ux-pro-max-skill": true
}
}
+13
View File
@@ -0,0 +1,13 @@
.venv/
__pycache__/
*.pyc
.git/
.env
*.db
master.xprv.enc
logs/
data/
.pytest_cache/
*.egg-info/
tests/
.claude/
+21
View File
@@ -0,0 +1,21 @@
DATABASE_URL=sqlite+aiosqlite:///./plm_lottery.db
ELECTRUM_HOST=santantonio.sytes.net
ELECTRUM_PORT=50002
ELECTRUM_USE_SSL=true
# Fernet key protecting the master xprv at rest. Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
XPRV_ENCRYPTION_KEY=
MASTER_KEY_PATH=./master.xprv.enc
# Random secret for JWT session signing. Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
JWT_SECRET=
# Bearer token required on the admin endpoints (X-Admin-Token header). Generate with:
# python -c "import secrets; print(secrets.token_urlsafe(32))"
ADMIN_TOKEN=
ROUND_DURATION_SECONDS=600
ROUND_COOLDOWN_SECONDS=30
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
.env
*.db
master.xprv.enc
.pytest_cache/
*.egg-info/
logs/
data/
+58 -1
View File
@@ -8,10 +8,52 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
This repository is at the **specification stage, not yet implemented**: it currently contains only [flowchart.mmd](flowchart.mmd), which is the source of truth for the project and describes the entire application flow. The tech stack is decided (see below) but no code, build system, or lint/test commands exist yet — once the project is scaffolded, this section must be updated with real commands (install, run, lint, test — including how to run a single test).
All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (49 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.
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.
Before writing code, always read [flowchart.mmd](flowchart.mmd) in full: every node in the diagram corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops).
## Commands
```bash
source .venv/bin/activate # venv already created at .venv/
pip install -e ".[dev]" # install/update deps
alembic upgrade head # apply DB migrations
alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py
PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env)
uvicorn app.main:app --reload --port 8123 # run the dev server
python -m pytest # run all tests
python -m pytest tests/unit/test_hd.py # run one test file
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run a single test
```
`.env` (gitignored) holds real secrets for local dev; `.env.example` documents the required keys and how to generate them.
## Deployment (Docker + Caddy)
`docker-compose.yml` runs two containers: `app` (this codebase, built by `Dockerfile`, runs `alembic upgrade head` then `uvicorn`) and `caddy` (reverse proxy + automatic TLS). `.env` still holds the app secrets; `docker-compose.yml` overrides `DATABASE_URL`/`MASTER_KEY_PATH` to point at the bind-mounted `./data/` (db, encrypted master key, logs — all gitignored, persist across container restarts).
```bash
mkdir -p data/db data/keys data/logs # one-time: host dirs bind-mounted into the app container
docker compose run --rm app python scripts/generate_master_key.py # one-time: create+encrypt the master xprv into ./data/keys/
docker compose up -d --build # build + start app and caddy
docker compose logs -f app # tail app logs (also written to ./data/logs/app.log)
docker compose down # stop
```
Caddy's site address comes from `SITE_ADDRESS` (env var on the host, read by `docker-compose.yml`):
- **Dev, no domain**: leave it unset (defaults to `localhost`). Caddy detects it isn't a public hostname and issues a self-signed cert from its own internal CA — browsers will warn on first visit, expected for local testing (`curl -k` or click through).
- **Production, with a domain**: `SITE_ADDRESS=lottery.example.com docker compose up -d` (DNS must already point at the server, ports 80+443 reachable). Caddy automatically requests and renews a real Let's Encrypt certificate — no other config needed.
Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a crash mid-round auto-restarts the container — which hits the scheduler-resume gap below (a round stuck in `closing`/`drawing`/`paying_out` at restart stays stuck). Don't treat this as unattended-safe until that gap is closed.
## Tech stack (MVP)
- **Backend language**: Python.
@@ -20,6 +62,7 @@ Before writing code, always read [flowchart.mmd](flowchart.mmd) in full: every n
- **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git.
- **Operational config** (fee/commission address, RBF fee-bump wallet, etc.): stored in a DB config table, not env vars — must be editable without a redeploy.
- **Round duration**: configurable via env var, default 10 minutes (not hardcoded).
- **Round cooldown**: `ROUND_COOLDOWN_SECONDS` (default 30s) — gap after a round closes before the next one opens, so players have time to see the outcome. Not in the original flowchart; added afterwards as an explicit design decision.
## PLM network parameters
@@ -65,3 +108,17 @@ These choices were made explicitly during design (not derivable from reading a s
- The user's personal deposit address always doubles as the winnings-receiving address: there is no separate "winner address".
- 1 confirmation is the chosen threshold for all tx types (deposits, bets, payouts, withdrawals): don't introduce different thresholds (e.g. 3 or 6 confirmations) without an explicit decision.
- The draw algorithm (node R) is deliberately simple and should be treated as a replaceable/pluggable component, not the final design — don't architect around its current implementation.
## Known gaps / TODO
Not blockers for reading the code, but must be addressed before this is production-ready:
- **Scheduler doesn't resume mid-flight rounds after a restart.** `rounds/scheduler.py`'s `_tick()` only acts on rounds with `status == "open"`. If the process restarts while a round is `closing`/`drawing`/`paying_out`, it's permanently stuck — nothing re-enters `_wait_for_next_block` or retries `_trigger_payout`. Needs a startup routine that inspects in-progress rounds and resumes (or a periodic "unstick" check) before this can run unattended.
- **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.
- **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 bets / withdrawals / past rounds) — only `/users/me` (balance) exists.
- **No deployment setup**: no Dockerfile, process manager, or reconnect/supervision beyond the in-process asyncio tasks. Currently only run manually via `uvicorn` in a dev venv.
- **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.
- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal).
- 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.
+11
View File
@@ -0,0 +1,11 @@
# SITE_ADDRESS is the domain to serve (e.g. lottery.example.com) — Caddy
# automatically requests a Let's Encrypt certificate for it.
#
# Left at the default "localhost" (dev mode, no domain), Caddy detects it's
# not a public hostname and issues a locally-trusted self-signed certificate
# instead, via its internal CA. Browsers will still warn on first visit
# unless that CA is explicitly trusted — expected for local/dev use.
{$SITE_ADDRESS:localhost} {
encode gzip
reverse_proxy app:8123
}
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml ./
COPY app ./app
COPY migrations ./migrations
COPY alembic.ini ./
COPY scripts ./scripts
RUN pip install --no-cache-dir .
EXPOSE 8123
CMD ["/bin/sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
View File
View File
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.session import get_session
from app.rounds.config import get_round_config
router = APIRouter(prefix="/admin", tags=["admin"])
async def require_admin(x_admin_token: str = Header(default="")) -> None:
if not settings.admin_token or x_admin_token != settings.admin_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
class RoundConfigResponse(BaseModel):
fee_address: str
bet_amount_sats: int
class RoundConfigUpdate(BaseModel):
fee_address: str | None = None
bet_amount_sats: int | None = None
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def read_config(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
config = await get_round_config(session)
await session.commit()
return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats)
@router.put("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
async def update_config(
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
) -> RoundConfigResponse:
config = await get_round_config(session)
if body.fee_address is not None:
config.fee_address = body.fee_address
if body.bet_amount_sats is not None:
config.bet_amount_sats = body.bet_amount_sats
await session.commit()
return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats)
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.bets.service import BetError, place_bet
from app.db.models import User
from app.db.session import get_session
router = APIRouter(prefix="/bets", tags=["bets"])
class BetResponse(BaseModel):
round_id: int
bet_txid: str
bet_amount_sats: int
status: str
@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED)
async def create_bet(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> BetResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly")
async with request.app.state.user_locks.acquire(user.id):
try:
participant = await place_bet(session, listener.client, user)
except BetError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return BetResponse(
round_id=participant.round_id,
bet_txid=participant.bet_txid,
bet_amount_sats=participant.bet_amount_sats,
status=participant.status,
)
+22
View File
@@ -0,0 +1,22 @@
import io
import re
import qrcode
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import Response
router = APIRouter(tags=["qr"])
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
@router.get("/qr/{address}")
async def address_qr(address: str) -> Response:
if not _ADDRESS_RE.match(address):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid address")
image = qrcode.make(address)
buf = io.BytesIO()
image.save(buf, format="PNG")
return Response(content=buf.getvalue(), media_type="image/png")
+50
View File
@@ -0,0 +1,50 @@
from datetime import timedelta, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.models import RoundParticipant
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.service import get_active_round
router = APIRouter(prefix="/rounds", tags=["rounds"])
class CurrentRoundResponse(BaseModel):
round_id: int | None = None
status: str | None = None
opened_at: str | None = None
closes_at: str | None = None
participant_count: int = 0
bet_amount_sats: int
jackpot_sats: int = 0
@router.get("/current", response_model=CurrentRoundResponse)
async def current_round(session: AsyncSession = Depends(get_session)) -> CurrentRoundResponse:
config = await get_round_config(session)
round_ = await get_active_round(session)
if round_ is None:
await session.commit()
return CurrentRoundResponse(bet_amount_sats=config.bet_amount_sats)
participant_count = await session.scalar(
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
) or 0
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
closes_at = opened_at + timedelta(seconds=settings.round_duration_seconds)
await session.commit()
return CurrentRoundResponse(
round_id=round_.id,
status=round_.status,
opened_at=opened_at.isoformat(),
closes_at=closes_at.isoformat(),
participant_count=participant_count,
bet_amount_sats=config.bet_amount_sats,
jackpot_sats=participant_count * config.bet_amount_sats,
)
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.auth.dependencies import get_current_user
from app.db.models import User
router = APIRouter(prefix="/users", tags=["users"])
class MeResponse(BaseModel):
username: str
address: str
balance_sats: int
@router.get("/me", response_model=MeResponse)
async def me(user: User = Depends(get_current_user)) -> MeResponse:
return MeResponse(username=user.username, address=user.address, balance_sats=user.cached_balance_sats)
+49
View File
@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.db.models import User
from app.db.session import get_session
from app.withdrawals.service import WithdrawalError, request_withdrawal
router = APIRouter(prefix="/withdrawals", tags=["withdrawals"])
class WithdrawalRequest(BaseModel):
external_address: str
amount_sats: int
class WithdrawalResponse(BaseModel):
txid: str
amount_requested_sats: int
amount_sent_sats: int
status: str
@router.post("", response_model=WithdrawalResponse, status_code=status.HTTP_201_CREATED)
async def create_withdrawal(
body: WithdrawalRequest,
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> WithdrawalResponse:
listener = request.app.state.electrum_listener
if listener.client is None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "not connected to the network, try again shortly")
async with request.app.state.user_locks.acquire(user.id):
try:
withdrawal = await request_withdrawal(
session, listener.client, user, body.external_address, body.amount_sats
)
except WithdrawalError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return WithdrawalResponse(
txid=withdrawal.txid,
amount_requested_sats=withdrawal.amount_requested_sats,
amount_sent_sats=withdrawal.amount_sent_sats,
status=withdrawal.status,
)
View File
+22
View File
@@ -0,0 +1,22 @@
import json
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import AuditLog
async def write_audit_log(
session: AsyncSession,
event_type: str,
payload: dict,
user_id: int | None = None,
round_id: int | None = None,
) -> None:
session.add(
AuditLog(
event_type=event_type,
payload_json=json.dumps(payload),
user_id=user_id,
round_id=round_id,
)
)
View File
+25
View File
@@ -0,0 +1,25 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.security import decode_access_token
from app.db.models import User
from app.db.session import get_session
_bearer = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
session: AsyncSession = Depends(get_session),
) -> User:
try:
user_id = decode_access_token(credentials.credentials)
except Exception as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") from exc
user = await session.scalar(select(User).where(User.id == user_id))
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
return user
+70
View File
@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.security import create_access_token, hash_password, verify_password
from app.db.models import User
from app.db.session import get_session
from app.wallet.hd import derive_user_address
router = APIRouter(prefix="/auth", tags=["auth"])
_MAX_REGISTER_RETRIES = 5
class RegisterRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
address: str
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(
body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session)
) -> TokenResponse:
existing = await session.scalar(select(User).where(User.username == body.username))
if existing is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "username already taken")
password_hash = hash_password(body.password)
for _ in range(_MAX_REGISTER_RETRIES):
max_index = await session.scalar(select(func.max(User.derivation_index)))
next_index = 0 if max_index is None else max_index + 1
address = derive_user_address(next_index)
user = User(
username=body.username,
password_hash=password_hash,
derivation_index=next_index,
address=address,
)
session.add(user)
try:
await session.commit()
except IntegrityError:
await session.rollback()
continue
await session.refresh(user)
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
raise HTTPException(status.HTTP_409_CONFLICT, "could not allocate a derivation index, retry")
class LoginRequest(BaseModel):
username: str
password: str
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse:
user = await session.scalar(select(User).where(User.username == body.username))
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
return TokenResponse(access_token=create_access_token(user.id), address=user.address)
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.config import settings
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
def create_access_token(user_id: int) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": str(user_id), "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> int:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return int(payload["sub"])
View File
+19
View File
@@ -0,0 +1,19 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingTransaction, RoundParticipant
from app.tx.confirmation import register_handler
async def _on_bet_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
participant = await session.scalar(
select(RoundParticipant).where(RoundParticipant.bet_txid == pending.current_txid)
)
if participant is not None and participant.status == "broadcast":
participant.status = "confirmed"
participant.confirmed_at = datetime.now(timezone.utc)
register_handler("bet", _on_bet_confirmed)
+106
View File
@@ -0,0 +1,106 @@
from datetime import datetime, timezone
from embit import script
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.config import settings
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.service import open_new_round_if_needed
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
class BetError(Exception):
pass
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
round_ = await open_new_round_if_needed(session)
if round_ is None:
raise BetError("no round open right now, please try again shortly")
if round_.status != "open":
raise BetError("the current round is closing, please try again shortly")
already_playing = await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
)
)
if already_playing is not None:
raise BetError("you already have an active bet in the current round")
config = await get_round_config(session)
bet_amount = config.bet_amount_sats
unspent = (
await session.scalars(
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:
raise BetError("insufficient balance")
user_key = derive_user_key(user.derivation_index)
from_script = script.p2wpkh(user_key.to_public())
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
try:
built = build_signed_transaction(
signing_key=user_key,
from_script=from_script,
utxos=utxos,
to_address=derive_pool_address(),
amount_sats=bet_amount,
change_address=user.address,
fee_rate_sat_vb=settings.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise BetError(str(exc)) from exc
await client.broadcast(built.raw_hex)
spent_by_key = {(u.txid, u.vout): u for u in unspent}
for spent in built.spent_utxos:
row = spent_by_key[(spent.txid, spent.vout)]
row.spent_txid = built.txid
await recompute_balance(session, user.id)
broadcast_at = datetime.now(timezone.utc)
participant = RoundParticipant(
round_id=round_.id,
user_id=user.id,
bet_amount_sats=built.recipient_sats,
bet_txid=built.txid,
broadcast_at=broadcast_at,
status="broadcast",
)
session.add(participant)
session.add(_pending_transaction(round_.id, user.id, built))
await write_audit_log(
session,
"bet_placed",
{"txid": built.txid, "amount_sats": built.recipient_sats},
user_id=user.id,
round_id=round_.id,
)
await session.commit()
await session.refresh(participant)
return participant
def _pending_transaction(round_id: int, user_id: int, built: BuiltTransaction) -> PendingTransaction:
return PendingTransaction(
kind="bet",
round_id=round_id,
user_id=user_id,
current_txid=built.txid,
fee_rate_sat_vb=settings.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
)
+30
View File
@@ -0,0 +1,30 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
electrum_host: str = "santantonio.sytes.net"
electrum_port: int = 50002
electrum_use_ssl: bool = True
xprv_encryption_key: str = ""
master_key_path: str = "./master.xprv.enc"
jwt_secret: str = ""
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24
admin_token: str = ""
round_duration_seconds: int = 600
round_cooldown_seconds: int = 30
bet_amount_sats: int = 10 * 100_000_000
min_amount_sats: int = 1 * 100_000_000
confirmations_required: int = 1
fee_rate_sat_vb: int = 1
rbf_timeout_seconds: int = 900
settings = Settings()
View File
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
+131
View File
@@ -0,0 +1,131 @@
from datetime import datetime, timezone
from sqlalchemy import BigInteger, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
derivation_index: Mapped[int] = mapped_column(unique=True)
address: Mapped[str] = mapped_column(String(128), unique=True)
# Read cache only; must always be written in the same transaction as the
# utxo_events rows it summarizes. Source of truth is utxo_events.
cached_balance_sats: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
class UtxoEvent(Base):
__tablename__ = "utxo_events"
__table_args__ = (UniqueConstraint("txid", "vout"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
txid: Mapped[str] = mapped_column(String(64))
vout: Mapped[int]
amount_sats: Mapped[int] = mapped_column(BigInteger)
confirmed_height: Mapped[int]
confirmed_at: Mapped[datetime] = mapped_column(default=utcnow)
# Set once this UTXO is consumed by an outgoing bet/withdrawal build.
spent_txid: Mapped[str | None] = mapped_column(String(64), default=None)
class Round(Base):
__tablename__ = "rounds"
id: Mapped[int] = mapped_column(primary_key=True)
status: Mapped[str] = mapped_column(String(16), default="open")
opened_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None] = mapped_column(default=None)
draw_block_height: Mapped[int | None] = mapped_column(default=None)
draw_block_hash: Mapped[str | None] = mapped_column(String(64), default=None)
seed_int: Mapped[str | None] = mapped_column(String(128), default=None)
winner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
pool_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
class RoundParticipant(Base):
__tablename__ = "round_participants"
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
bet_txid: Mapped[str] = mapped_column(String(64))
# Ordering / tie-break field per spec: broadcast time, not confirmation time.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
status: Mapped[str] = mapped_column(String(16), default="broadcast")
class RoundConfig(Base):
"""Single-row operational config, DB-backed so it's editable without a redeploy.
round_duration is intentionally NOT here: it stays env-var-driven per spec.
Don't move it here without an explicit decision to change that.
"""
__tablename__ = "round_config"
id: Mapped[int] = mapped_column(primary_key=True)
fee_address: Mapped[str] = mapped_column(String(128))
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class PendingTransaction(Base):
"""Single source of truth for the RBF timeout->bump->rebroadcast loop."""
__tablename__ = "pending_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[str] = mapped_column(String(16)) # bet | payout | withdrawal
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
withdrawal_id: Mapped[int | None] = mapped_column(ForeignKey("withdrawals.id"), default=None)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
current_txid: Mapped[str] = mapped_column(String(64))
fee_rate_sat_vb: Mapped[int]
raw_tx_hex: Mapped[str] = mapped_column(String)
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
status: Mapped[str] = mapped_column(String(16), default="pending")
replaced_by_txid: Mapped[str | None] = mapped_column(String(64), default=None)
attempt_count: Mapped[int] = mapped_column(default=1)
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
class Withdrawal(Base):
__tablename__ = "withdrawals"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
external_address: Mapped[str] = mapped_column(String(128))
amount_requested_sats: Mapped[int] = mapped_column(BigInteger)
amount_sent_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
txid: Mapped[str | None] = mapped_column(String(64), default=None)
status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
event_type: Mapped[str] = mapped_column(String(32))
payload_json: Mapped[str] = mapped_column(String)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None)
created_at: Mapped[datetime] = mapped_column(default=utcnow)
+10
View File
@@ -0,0 +1,10 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import AsyncSessionLocal
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
View File
+54
View File
@@ -0,0 +1,54 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.models import UtxoEvent
from app.wallet.balance import recompute_balance
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
balance. Returns the number of newly-credited UTXOs.
entries: [{"tx_hash": ..., "tx_pos": ..., "height": ..., "value": ...}, ...]
height <= 0 means unconfirmed (mempool) per the Electrum protocol convention —
skipped, since the spec requires 1 confirmation before crediting.
"""
existing_keys = {
(txid, vout)
for txid, vout in (
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
).all()
}
newly_credited = 0
for entry in entries:
if entry["height"] <= 0:
continue
key = (entry["tx_hash"], entry["tx_pos"])
if key in existing_keys:
continue
session.add(
UtxoEvent(
user_id=user_id,
txid=entry["tx_hash"],
vout=entry["tx_pos"],
amount_sats=entry["value"],
confirmed_height=entry["height"],
)
)
await write_audit_log(
session,
"deposit_credited",
{"txid": entry["tx_hash"], "vout": entry["tx_pos"], "amount_sats": entry["value"]},
user_id=user_id,
)
newly_credited += 1
if newly_credited:
await session.flush()
await recompute_balance(session, user_id)
await session.commit()
return newly_credited
View File
+114
View File
@@ -0,0 +1,114 @@
import asyncio
import itertools
import json
import ssl
class ElectrumError(Exception):
pass
class ElectrumClient:
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
Push notifications (blockchain.headers.subscribe, blockchain.scripthash.subscribe)
arrive under the *same* method name as the subscribe call, multiplexed for every
scripthash subscribed — callers read `notifications(method)` and, for scripthash
pushes, dispatch on `params[0]` (the scripthash) themselves.
"""
def __init__(self, host: str, port: int, use_ssl: bool = True):
self.host = host
self.port = port
self.use_ssl = use_ssl
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._id_counter = itertools.count(1)
self._pending: dict[int, asyncio.Future] = {}
self._subscriptions: dict[str, asyncio.Queue] = {}
self._read_task: asyncio.Task | None = None
async def connect(self) -> None:
# Electrum servers commonly present self-signed certs; the protocol's trust
# model is server consensus, not TLS PKI, so we only use SSL for transport
# encryption and don't verify the certificate chain/hostname.
ssl_context = None
if self.use_ssl:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self._reader, self._writer = await asyncio.open_connection(self.host, self.port, ssl=ssl_context)
self._read_task = asyncio.create_task(self._read_loop())
await self.request("server.version", ["plm-lottery", "1.4"])
async def close(self) -> None:
if self._read_task is not None:
self._read_task.cancel()
if self._writer is not None:
self._writer.close()
try:
await asyncio.wait_for(self._writer.wait_closed(), timeout=2)
except (ssl.SSLError, TimeoutError, asyncio.TimeoutError):
pass # some Electrum servers don't send a clean TLS close_notify
async def request(self, method: str, params: list | None = None) -> object:
if self._writer is None:
raise ElectrumError("not connected")
request_id = next(self._id_counter)
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[request_id] = future
payload = json.dumps({"id": request_id, "method": method, "params": params or []}) + "\n"
self._writer.write(payload.encode())
await self._writer.drain()
return await future
def notifications(self, method: str) -> asyncio.Queue:
return self._subscriptions.setdefault(method, asyncio.Queue())
async def subscribe_headers(self) -> dict:
self.notifications("blockchain.headers.subscribe")
return await self.request("blockchain.headers.subscribe")
async def subscribe_scripthash(self, scripthash: str) -> str | None:
self.notifications("blockchain.scripthash.subscribe")
return await self.request("blockchain.scripthash.subscribe", [scripthash])
async def listunspent(self, scripthash: str) -> list[dict]:
return await self.request("blockchain.scripthash.listunspent", [scripthash])
async def broadcast(self, raw_tx_hex: str) -> str:
return await self.request("blockchain.transaction.broadcast", [raw_tx_hex])
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
return await self.request("blockchain.transaction.get", [txid, verbose])
async def _read_loop(self) -> None:
assert self._reader is not None
try:
while True:
line = await self._reader.readline()
if not line:
break
message = json.loads(line)
self._dispatch(message)
finally:
error = ElectrumError("connection closed")
for future in self._pending.values():
if not future.done():
future.set_exception(error)
self._pending.clear()
def _dispatch(self, message: dict) -> None:
message_id = message.get("id")
if message_id is not None and message_id in self._pending:
future = self._pending.pop(message_id)
if future.done():
return
if message.get("error"):
future.set_exception(ElectrumError(message["error"]))
else:
future.set_result(message.get("result"))
elif "method" in message:
queue = self._subscriptions.get(message["method"])
if queue is not None:
queue.put_nowait(message.get("params"))
+111
View File
@@ -0,0 +1,111 @@
import asyncio
import logging
from collections.abc import Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
from app.electrum.client import ElectrumClient
from app.electrum.scripthash import address_to_scripthash
logger = logging.getLogger(__name__)
class ElectrumListener:
"""Long-lived background task: keeps one Electrum connection open, subscribes
every user's address (plus any address added later via add_address), and
credits confirmed deposits as scripthash-change notifications arrive.
Reconnects with backoff on any failure; a fresh connection re-subscribes to
every user pulled straight from the DB, so no in-memory subscription state is
ever a stale source of truth.
"""
def __init__(self, client_factory: Callable[[], ElectrumClient], session_factory: async_sessionmaker):
self._client_factory = client_factory
self._session_factory = session_factory
self._scripthash_to_user: dict[str, int] = {}
self.tip_height: int = 0
self.tip_header_hex: str | None = None
self.client: ElectrumClient | None = None
def address_for_new_user(self, user_id: int, address: str) -> None:
"""Called right after a user registers so their deposit address starts
being watched immediately, without waiting for the next reconnect cycle."""
scripthash = address_to_scripthash(address)
self._scripthash_to_user[scripthash] = user_id
if self.client is not None:
asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id))
async def run(self) -> None:
backoff = 1
while True:
try:
await self._run_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Electrum listener error, reconnecting in %ss", backoff)
self.client = None
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
backoff = 1
async def _run_once(self) -> None:
client = self._client_factory()
await client.connect()
self.client = client
header = await client.subscribe_headers()
self.tip_height = header["height"]
self.tip_header_hex = header.get("hex")
await self._subscribe_all_users()
headers_queue = client.notifications("blockchain.headers.subscribe")
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
try:
await asyncio.gather(
self._consume_headers(headers_queue),
self._consume_scripthash(scripthash_queue),
)
finally:
await client.close()
async def _subscribe_all_users(self) -> None:
async with self._session_factory() as session:
users = (await session.scalars(select(User))).all()
for user in users:
scripthash = address_to_scripthash(user.address)
self._scripthash_to_user[scripthash] = user.id
await self._subscribe_and_refresh(scripthash, user.id)
async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None:
assert self.client is not None
await self.client.subscribe_scripthash(scripthash)
await self._refresh_user(user_id, scripthash)
async def _consume_headers(self, queue: asyncio.Queue) -> None:
while True:
params = await queue.get()
for header in params:
self.tip_height = header["height"]
self.tip_header_hex = header.get("hex")
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
while True:
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)
async def _refresh_user(self, user_id: int, scripthash: str) -> None:
assert self.client is not None
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
if credited:
logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id)
+15
View File
@@ -0,0 +1,15 @@
import hashlib
from embit.script import Script
def address_to_scripthash(address: str) -> str:
"""Electrum protocol scripthash: sha256(scriptPubKey), byte-reversed, hex.
Uses `.data` (the raw scriptPubKey bytes), not `.serialize()` — the latter
prefixes a compact-size length byte meant for embedding the script as pushdata
elsewhere (e.g. a P2SH redeemScript), which is not part of the actual on-chain
output script and produces a wrong (unmatchable) scripthash if used here.
"""
script_pubkey = Script.from_address(address).data
return hashlib.sha256(script_pubkey).digest()[::-1].hex()
+28
View File
@@ -0,0 +1,28 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
LOG_FILE = LOG_DIR / "app.log"
_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
def setup_logging() -> None:
"""Route all app + uvicorn logging to logs/app.log so errors are traceable
without depending on however the process happens to be launched."""
LOG_DIR.mkdir(exist_ok=True)
handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
handler.setFormatter(logging.Formatter(_FORMAT))
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
# "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default
# logging config, so they never reach the root handler above and need their
# own. The parent "uvicorn" logger is deliberately skipped: uvicorn.error
# already bubbles into it, so attaching there too would double every line.
for logger_name in ("uvicorn.error", "uvicorn.access"):
logging.getLogger(logger_name).addHandler(handler)
+90
View File
@@ -0,0 +1,90 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app.logging_config import setup_logging
setup_logging()
import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler)
import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler)
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
from app.api.routes.admin import router as admin_router
from app.api.routes.bets import router as bets_router
from app.api.routes.qr import router as qr_router
from app.api.routes.rounds import router as rounds_router
from app.api.routes.users import router as users_router
from app.api.routes.withdrawals import router as withdrawals_router
from app.auth.routes import router as auth_router
from app.config import settings
from app.db.base import AsyncSessionLocal
from app.electrum.client import ElectrumClient
from app.electrum.listener import ElectrumListener
from app.rounds.scheduler import RoundScheduler
from app.tx.broadcast import RbfBumper
from app.tx.confirmation import ConfirmationPoller
from app.tx.locks import UserLocks
logger = logging.getLogger(__name__)
def _make_electrum_client() -> ElectrumClient:
return ElectrumClient(settings.electrum_host, settings.electrum_port, settings.electrum_use_ssl)
@asynccontextmanager
async def lifespan(app: FastAPI):
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal)
app.state.electrum_listener = listener
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
tasks = [
asyncio.create_task(listener.run()),
asyncio.create_task(scheduler.run()),
asyncio.create_task(poller.run()),
asyncio.create_task(bumper.run()),
]
try:
yield
finally:
for task in tasks:
task.cancel()
if listener.client is not None:
await listener.client.close()
app = FastAPI(title="PLM Lottery", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(bets_router)
app.include_router(withdrawals_router)
app.include_router(admin_router)
app.include_router(qr_router)
app.include_router(rounds_router)
@app.exception_handler(Exception)
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "internal server error"})
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/admin", include_in_schema=False)
async def admin_panel() -> FileResponse:
return FileResponse("app/static/admin.html")
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
View File
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.models import RoundConfig
async def get_round_config(session: AsyncSession) -> RoundConfig:
"""Single-row operational config, lazily seeded from settings defaults on
first use. fee_address starts empty until an operator sets it (admin
endpoint, stage 10) — payouts must refuse to run until it's set."""
config = await session.scalar(select(RoundConfig))
if config is None:
config = RoundConfig(fee_address="", bet_amount_sats=settings.bet_amount_sats)
session.add(config)
await session.flush()
return config
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingTransaction, Round
from app.tx.confirmation import register_handler
async def _on_payout_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
round_ = await session.scalar(select(Round).where(Round.payout_txid == pending.current_txid))
if round_ is not None and round_.status == "paying_out":
round_.status = "closed"
# The winner's own address is already watched by the Electrum listener, so
# their balance is credited by the normal deposit path once this confirms.
register_handler("payout", _on_payout_confirmed)
+21
View File
@@ -0,0 +1,21 @@
import hashlib
def header_hex_to_block_hash(header_hex: str) -> str:
"""Block hash from a raw Electrum header: sha256d, byte-reversed, hex.
Verified against a real mainnet block (blockchain.transaction.get's own
reported blockhash) during development."""
header_bytes = bytes.fromhex(header_hex)
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
return digest[::-1].hex()
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
"""v1 draw algorithm (flowchart.mmd, node R): seed = block hash as an integer,
index = seed mod participant_count, winner = participants[index]. Anyone can
recompute and verify it from public data. Deliberately simple/replaceable."""
if not participants:
raise ValueError("no participants to draw from")
seed = int(block_hash_hex, 16)
index = seed % len(participants)
return participants[index]
+208
View File
@@ -0,0 +1,208 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from embit import script
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.audit.log import write_audit_log
from app.config import settings
from app.db.models import PendingTransaction, Round, RoundParticipant, User
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.config import get_round_config
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_pool_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
logger = logging.getLogger(__name__)
_TICK_INTERVAL_SECONDS = 5
class RoundScheduler:
"""Background task implementing flowchart.mmd's DRAW subgraph: closes the
round on its timer (once any in-flight bets have confirmed), draws a winner
from the next confirmed block, and broadcasts the payout. The next round only
opens once this one is fully closed (rounds/service.get_active_round)."""
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
self._session_factory = session_factory
self._listener = listener
async def run(self) -> None:
while True:
try:
await self._tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("round scheduler tick failed")
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
async def _tick(self) -> None:
if self._listener.client is None:
return
async with self._session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
if round_ is None:
return # still in the cooldown window after the last round closed
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
if status != "open":
return # already closing/drawing/paying_out; progress happens elsewhere
opened_at = opened_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=settings.round_duration_seconds):
return
async with self._session_factory() as session:
pending_count = await session.scalar(
select(func.count())
.select_from(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "broadcast")
)
if pending_count:
return # wait for in-flight bets to confirm before closing
await self._close_and_draw(round_id)
async def _close_and_draw(self, round_id: int) -> None:
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
round_.status = "closing"
round_.closed_at = datetime.now(timezone.utc)
participants = (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
.order_by(RoundParticipant.broadcast_at)
)
).all()
if not participants:
round_.status = "closed"
await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id)
await session.commit()
logger.info("round %s closed with no participants", round_id)
return
pool_amount = sum(p.bet_amount_sats for p in participants)
addresses: list[str] = []
user_by_address: dict[str, int] = {}
for p in participants:
user = await session.get(User, p.user_id)
addresses.append(user.address)
user_by_address[user.address] = user.id
round_.status = "drawing"
await session.commit()
tip_at_close = self._listener.tip_height
block_height, block_hash = await self._wait_for_next_block(tip_at_close)
winner_address = draw_winner(addresses, block_hash)
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
round_.draw_block_height = block_height
round_.draw_block_hash = block_hash
round_.seed_int = str(int(block_hash, 16))
round_.winner_user_id = user_by_address[winner_address]
round_.pool_amount_sats = pool_amount
round_.status = "paying_out"
await write_audit_log(
session,
"winner_drawn",
{
"winner_address": winner_address,
"pool_amount_sats": pool_amount,
"block_height": block_height,
"block_hash": block_hash,
"participants": len(addresses),
},
user_id=user_by_address[winner_address],
round_id=round_id,
)
await session.commit()
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
await self._trigger_payout(round_id)
async def _wait_for_next_block(self, tip_at_close: int) -> tuple[int, str]:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex)
async def _trigger_payout(self, round_id: int) -> None:
client = self._listener.client
if client is None:
logger.error("round %s payout deferred: not connected", round_id)
return
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
config = await get_round_config(session)
if not config.fee_address:
logger.error(
"round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id
)
return
winner = await session.get(User, round_.winner_user_id)
winner_share = round_.pool_amount_sats * 70 // 100
commission_share = round_.pool_amount_sats - winner_share # remainder from rounding goes to fees
pool_key = derive_pool_key()
pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET)
pool_scripthash = address_to_scripthash(pool_address)
entries = await client.listunspent(pool_scripthash)
utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0]
try:
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script_obj,
utxos=utxos,
winner_address=winner.address,
winner_share_sats=winner_share,
fee_address=config.fee_address,
commission_sats=commission_share,
change_address=pool_address,
fee_rate_sat_vb=settings.fee_rate_sat_vb,
)
except InsufficientFundsError:
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
return
await client.broadcast(built.raw_hex)
round_.winner_amount_sats = built.winner_sats
round_.fee_amount_sats = built.commission_sats
round_.payout_txid = built.txid
session.add(
PendingTransaction(
kind="payout",
round_id=round_id,
current_txid=built.txid,
fee_rate_sat_vb=settings.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
)
)
await write_audit_log(
session,
"payout_sent",
{"txid": built.txid, "winner_sats": built.winner_sats, "commission_sats": built.commission_sats},
user_id=round_.winner_user_id,
round_id=round_id,
)
await session.commit()
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.models import Round
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
async def get_active_round(session: AsyncSession) -> Round | None:
"""The round currently in progress (in any non-closed state), if any. Rounds
never overlap: a new round only opens once the previous one is fully closed
(payout confirmed, or no participants to pay out)."""
return await session.scalar(select(Round).where(Round.status.in_(_ACTIVE_STATUSES)).order_by(Round.id.desc()))
async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
"""Returns the active round if one exists (whatever its status). Otherwise
opens a fresh one, unless the last closed round's cooldown (ROUND_COOLDOWN_SECONDS)
hasn't elapsed yet — in which case returns None. Callers that need to attach a
bet must additionally check the returned round's status == "open" — a round in
closing/drawing/paying_out isn't accepting new bets, but a new round can't open
until it's done."""
active = await get_active_round(session)
if active is not None:
return active
last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc()))
if last_closed is not None and last_closed.closed_at is not None:
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=settings.round_cooldown_seconds):
return None
round_ = Round(status="open")
session.add(round_)
await session.flush()
return round_
+199
View File
@@ -0,0 +1,199 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PLM Lottery — Admin</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600;700&display=swap');
:root {
--color-background: #F8FAFC;
--color-surface: #FFFFFF;
--color-foreground: #0F172A;
--color-muted-foreground: #64748B;
--color-border: #E2E8F0;
--color-primary: #F59E0B;
--color-on-primary: #0F172A;
--color-destructive: #DC2626;
--color-destructive-bg: #FEF2F2;
--color-success: #16A34A;
--color-success-bg: #F0FDF4;
--color-ring: #F59E0B;
--radius: 12px;
}
* { box-sizing: border-box; }
body {
font-family: 'Fira Sans', system-ui, sans-serif;
background: var(--color-background);
color: var(--color-foreground);
max-width: 480px;
margin: 0 auto;
padding: 32px 20px 80px;
line-height: 1.5;
}
.mono { font-family: 'Fira Code', monospace; }
header { margin-bottom: 24px; }
header h1 { font-size: 1.375rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
header p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 4px 0 0; }
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card .hint { color: var(--color-muted-foreground); font-size: 0.85rem; margin: 0 0 14px; }
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 12px; margin-bottom: 6px; }
label:first-child { margin-top: 0; }
input {
width: 100%; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface);
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
}
input:focus {
outline: none; border-color: var(--color-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
}
button {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
min-height: 44px; padding: 0 18px; margin-top: 16px; width: 100%;
font-family: inherit; font-size: 0.95rem; font-weight: 600;
background: var(--color-primary); color: var(--color-on-primary);
border: none; border-radius: 8px; cursor: pointer;
transition: filter 150ms, transform 150ms;
}
button:hover { filter: brightness(0.94); }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.6; cursor: default; }
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
button.secondary {
background: var(--color-background); color: var(--color-foreground);
border: 1px solid var(--color-border);
}
.hidden { display: none !important; }
#toast-container {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
}
.toast {
padding: 12px 14px; border-radius: 8px; font-size: 0.85rem; font-weight: 500;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
animation: toast-in 200ms ease-out;
}
.toast.success { background: var(--color-success-bg); color: var(--color-success); }
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<header>
<h1>PLM Lottery — Admin</h1>
<p>Configurazione operativa del round</p>
</header>
<div class="card">
<p class="hint">
Salvata nel database, modificabile in qualsiasi momento senza riavviare il server.
Serve il token admin (<code>ADMIN_TOKEN</code> nel <code>.env</code> del server).
</p>
<label for="admin-token">Admin token</label>
<input id="admin-token" type="password" placeholder="valore di ADMIN_TOKEN">
<button class="secondary" onclick="adminLoad()" id="load-btn">Carica configurazione attuale</button>
<div id="admin-form" class="hidden">
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
<input id="admin-fee-address" class="mono" placeholder="plm1q...">
<label for="admin-bet-amount">Bet amount (PLM)</label>
<input id="admin-bet-amount" inputmode="decimal" placeholder="es. 10">
<button onclick="adminSave()" id="save-btn">Salva</button>
</div>
</div>
<div id="toast-container" aria-live="polite"></div>
<script>
const SATS_PER_PLM = 100000000;
function toast(message, type) {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
async function withLoading(button, label, fn) {
const original = button.textContent;
button.disabled = true;
button.textContent = label;
try {
await fn();
} finally {
button.disabled = false;
button.textContent = original;
}
}
async function callAdmin(method, path, body) {
const adminToken = document.getElementById('admin-token').value;
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
async function adminLoad() {
const btn = document.getElementById('load-btn');
await withLoading(btn, 'Caricamento…', async () => {
try {
const data = await callAdmin('GET', '/admin/config');
document.getElementById('admin-fee-address').value = data.fee_address;
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
document.getElementById('admin-form').classList.remove('hidden');
toast('Configurazione caricata.', 'success');
} catch (e) {
toast('Errore: ' + e.message, 'error');
}
});
}
async function adminSave() {
const btn = document.getElementById('save-btn');
const feeAddress = document.getElementById('admin-fee-address').value;
const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value);
const betAmountSats = Math.round(betAmountPlm * SATS_PER_PLM);
await withLoading(btn, 'Salvataggio…', async () => {
try {
await callAdmin('PUT', '/admin/config', { fee_address: feeAddress, bet_amount_sats: betAmountSats });
toast('Configurazione salvata.', 'success');
} catch (e) {
toast('Errore nel salvataggio: ' + e.message, 'error');
}
});
}
</script>
</body>
</html>
+486
View File
@@ -0,0 +1,486 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PLM Lottery — Test</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600;700&display=swap');
:root {
--color-background: #F8FAFC;
--color-surface: #FFFFFF;
--color-foreground: #0F172A;
--color-muted-foreground: #64748B;
--color-border: #E2E8F0;
--color-primary: #F59E0B;
--color-on-primary: #0F172A;
--color-accent: #7C3AED;
--color-destructive: #DC2626;
--color-destructive-bg: #FEF2F2;
--color-success: #16A34A;
--color-success-bg: #F0FDF4;
--color-ring: #F59E0B;
--radius: 12px;
}
* { box-sizing: border-box; }
body {
font-family: 'Fira Sans', system-ui, sans-serif;
background: var(--color-background);
color: var(--color-foreground);
max-width: 480px;
margin: 0 auto;
padding: 32px 20px 80px;
line-height: 1.5;
}
.mono { font-family: 'Fira Code', monospace; }
header { margin-bottom: 24px; }
header h1 { font-size: 1.375rem; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
header p { color: var(--color-muted-foreground); font-size: 0.9rem; margin: 4px 0 0; }
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card h2 { font-size: 1rem; font-weight: 600; margin: 0 0 4px; }
.card .hint { color: var(--color-muted-foreground); font-size: 0.85rem; margin: 0 0 14px; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--color-border); }
.tab {
flex: 1; text-align: center; padding: 10px 0; font-weight: 600; font-size: 0.9rem;
color: var(--color-muted-foreground); cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: color 150ms, border-color 150ms;
}
.tab.active { color: var(--color-foreground); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 12px; margin-bottom: 6px; }
label:first-child { margin-top: 0; }
input {
width: 100%; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface);
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
}
input:focus {
outline: none; border-color: var(--color-ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
}
button {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
min-height: 44px; padding: 0 18px; margin-top: 16px; width: 100%;
font-family: inherit; font-size: 0.95rem; font-weight: 600;
background: var(--color-primary); color: var(--color-on-primary);
border: none; border-radius: 8px; cursor: pointer;
transition: filter 150ms, transform 150ms;
}
button:hover { filter: brightness(0.94); }
button:active { transform: scale(0.98); }
button:disabled { opacity: 0.6; cursor: default; }
button:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
button.secondary {
width: auto; margin-top: 0; padding: 0 12px; min-height: 36px;
background: var(--color-background); color: var(--color-foreground);
border: 1px solid var(--color-border);
}
button.link {
width: auto; margin-top: 0; padding: 0; min-height: auto;
background: none; color: var(--color-muted-foreground); font-weight: 500;
font-size: 0.85rem; text-decoration: underline;
}
button.link:hover { filter: none; color: var(--color-foreground); }
.account-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.account-bar .name { font-weight: 600; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.address-box {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
background: var(--color-background); border: 1px solid var(--color-border);
border-radius: 8px; padding: 10px 12px; font-size: 0.85rem; word-break: break-all;
}
.balance-value { font-size: 2rem; font-weight: 700; }
.balance-unit { color: var(--color-muted-foreground); font-size: 1rem; font-weight: 500; }
.icon { width: 16px; height: 16px; flex-shrink: 0; }
nav.menu { display: flex; gap: 4px; margin-bottom: 16px; }
nav.menu button.nav-item {
flex: 1; width: auto; margin-top: 0; min-height: 56px; padding: 8px 4px;
flex-direction: column; gap: 4px; font-size: 0.8rem; font-weight: 600;
background: var(--color-surface); color: var(--color-muted-foreground);
border: 1px solid var(--color-border); border-radius: 10px;
}
nav.menu button.nav-item .icon { width: 20px; height: 20px; }
nav.menu button.nav-item.active {
background: var(--color-primary); color: var(--color-on-primary); border-color: var(--color-primary);
}
nav.menu button.nav-item:hover { filter: none; border-color: var(--color-ring); }
nav.menu button.nav-item.active:hover { filter: brightness(0.94); }
.dash-panel { display: none; }
.dash-panel.active { display: block; }
.qr-box { display: flex; justify-content: center; padding: 16px; background: #fff; border: 1px solid var(--color-border); border-radius: 10px; margin-top: 14px; }
.qr-box img { width: 200px; height: 200px; image-rendering: pixelated; }
.hidden { display: none !important; }
#toast-container {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px; z-index: 100; width: calc(100% - 40px); max-width: 440px;
}
.toast {
display: flex; align-items: flex-start; gap: 8px;
padding: 12px 14px; border-radius: 8px; font-size: 0.85rem; font-weight: 500;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
animation: toast-in 200ms ease-out;
}
.toast.success { background: var(--color-success-bg); color: var(--color-success); }
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
</style>
</head>
<body>
<header>
<h1>PLM Lottery</h1>
<p>Dashboard di test — mainnet reale</p>
</header>
<section id="auth-section" class="card">
<div class="tabs">
<div class="tab active" id="tab-login" onclick="switchTab('login')">Login</div>
<div class="tab" id="tab-register" onclick="switchTab('register')">Registrati</div>
</div>
<div class="tab-panel active" id="panel-login">
<label for="login-username">Username</label>
<input id="login-username" autocomplete="username">
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password">
<button onclick="login()" id="login-btn">Accedi</button>
</div>
<div class="tab-panel" id="panel-register">
<label for="reg-username">Username</label>
<input id="reg-username" autocomplete="username">
<label for="reg-password">Password</label>
<input id="reg-password" type="password" autocomplete="new-password">
<button onclick="register()" id="register-btn">Crea account</button>
</div>
</section>
<section id="dashboard-section" class="hidden">
<div class="card">
<div class="account-bar">
<span class="name" id="dash-username"></span>
<button class="link" onclick="logout()">Esci</button>
</div>
</div>
<div class="card">
<div class="row-between">
<h2 id="round-title">Round —</h2>
<span class="mono" id="round-timer" style="font-size:1.1rem;font-weight:700">--:--</span>
</div>
<div class="row-between" style="margin-top:10px">
<div>
<div class="hint" style="margin-bottom:2px">Giocatori</div>
<span class="mono" id="round-players"></span>
</div>
<div style="text-align:right">
<div class="hint" style="margin-bottom:2px">Jackpot</div>
<span class="mono" id="round-jackpot"></span> <span class="balance-unit">PLM</span>
</div>
</div>
</div>
<nav class="menu" aria-label="Sezioni">
<button class="nav-item active" id="nav-deposit" onclick="switchPanel('deposit')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v16"/></svg>
Deposito
</button>
<button class="nav-item" id="nav-bet" onclick="switchPanel('bet')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 12h.01M12 12h.01M18 12h.01"/></svg>
Bet
</button>
<button class="nav-item" id="nav-withdraw" onclick="switchPanel('withdraw')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
Prelievo
</button>
</nav>
<div class="dash-panel active" id="panel-deposit">
<div class="card">
<h2>Saldo interno</h2>
<p class="hint">Aggiornato dopo 1 conferma sulla rete</p>
<div class="row-between">
<div><span class="balance-value mono" id="dash-balance"></span> <span class="balance-unit">PLM</span></div>
<button class="secondary" onclick="refreshMe()" id="refresh-btn" aria-label="Aggiorna saldo">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg>
Aggiorna
</button>
</div>
</div>
<div class="card">
<h2>Indirizzo di deposito</h2>
<p class="hint">È anche l'indirizzo su cui ricevi eventuali vincite</p>
<div class="address-box">
<span class="mono" id="dash-address"></span>
<button class="secondary" onclick="copyAddress()" aria-label="Copia indirizzo">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
<div class="qr-box">
<img id="dash-qr" alt="QR code dell'indirizzo di deposito">
</div>
</div>
</div>
<div class="dash-panel" id="panel-bet">
<div class="card">
<h2>Bet</h2>
<p class="hint">Ingresso fisso al round corrente</p>
<button onclick="placeBet()" id="bet-btn">Piazza bet (10 PLM)</button>
</div>
</div>
<div class="dash-panel" id="panel-withdraw">
<div class="card">
<h2>Withdrawal</h2>
<p class="hint">Invia fondi a un indirizzo PLM esterno</p>
<label for="wd-address">Indirizzo esterno</label>
<input id="wd-address" class="mono" placeholder="plm1q...">
<label for="wd-amount">Importo (PLM)</label>
<input id="wd-amount" inputmode="decimal" placeholder="es. 2">
<button onclick="withdraw()" id="withdraw-btn">Preleva</button>
</div>
</div>
</section>
<div id="toast-container" aria-live="polite"></div>
<script>
const SATS_PER_PLM = 100000000;
let token = localStorage.getItem('plm_token');
let username = localStorage.getItem('plm_username');
let address = localStorage.getItem('plm_address');
function toast(message, type) {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
async function withLoading(button, label, fn) {
const original = button.textContent;
button.disabled = true;
button.textContent = label;
try {
await fn();
} finally {
button.disabled = false;
button.textContent = original;
}
}
async function call(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
function switchTab(name) {
document.getElementById('tab-login').classList.toggle('active', name === 'login');
document.getElementById('tab-register').classList.toggle('active', name === 'register');
document.getElementById('panel-login').classList.toggle('active', name === 'login');
document.getElementById('panel-register').classList.toggle('active', name === 'register');
}
function switchPanel(name) {
for (const key of ['deposit', 'bet', 'withdraw']) {
document.getElementById('nav-' + key).classList.toggle('active', key === name);
document.getElementById('panel-' + key).classList.toggle('active', key === name);
}
}
let roundCloseAt = null;
let roundTimerInterval = null;
let roundPollInterval = null;
const ROUND_STATUS_LABELS = {
open: 'aperto',
closing: 'in chiusura',
drawing: 'estrazione in corso',
paying_out: 'pagamento in corso',
};
function updateRoundTimer() {
const el = document.getElementById('round-timer');
if (!roundCloseAt) { el.textContent = '--:--'; return; }
const totalSec = Math.max(0, Math.floor((roundCloseAt - new Date()) / 1000));
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
const ss = String(totalSec % 60).padStart(2, '0');
el.textContent = mm + ':' + ss;
}
async function refreshRound() {
try {
const data = await call('GET', '/rounds/current');
document.getElementById('round-title').textContent = data.round_id
? 'Round #' + data.round_id + ' — ' + (ROUND_STATUS_LABELS[data.status] || data.status)
: 'Nessun round attivo';
document.getElementById('round-players').textContent = data.participant_count;
document.getElementById('round-jackpot').textContent = data.jackpot_sats / SATS_PER_PLM;
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
updateRoundTimer();
} catch (e) {}
}
function showDashboard() {
document.getElementById('auth-section').classList.add('hidden');
document.getElementById('dashboard-section').classList.remove('hidden');
document.getElementById('dash-username').textContent = username;
document.getElementById('dash-address').textContent = address;
document.getElementById('dash-qr').src = '/qr/' + encodeURIComponent(address);
refreshMe();
refreshRound();
clearInterval(roundTimerInterval);
clearInterval(roundPollInterval);
roundTimerInterval = setInterval(updateRoundTimer, 1000);
roundPollInterval = setInterval(refreshRound, 15000);
}
function persistSession(data, u) {
token = data.access_token; username = u; address = data.address;
localStorage.setItem('plm_token', token);
localStorage.setItem('plm_username', username);
localStorage.setItem('plm_address', address);
}
async function register() {
const btn = document.getElementById('register-btn');
const u = document.getElementById('reg-username').value;
const p = document.getElementById('reg-password').value;
await withLoading(btn, 'Creazione…', async () => {
try {
const data = await call('POST', '/auth/register', { username: u, password: p });
persistSession(data, u);
toast('Account creato.', 'success');
showDashboard();
} catch (e) {
toast(e.message, 'error');
}
});
}
async function login() {
const btn = document.getElementById('login-btn');
const u = document.getElementById('login-username').value;
const p = document.getElementById('login-password').value;
await withLoading(btn, 'Accesso…', async () => {
try {
const data = await call('POST', '/auth/login', { username: u, password: p });
persistSession(data, u);
toast('Accesso riuscito.', 'success');
showDashboard();
} catch (e) {
toast(e.message, 'error');
}
});
}
function logout() {
localStorage.clear();
token = username = address = null;
clearInterval(roundTimerInterval);
clearInterval(roundPollInterval);
document.getElementById('dashboard-section').classList.add('hidden');
document.getElementById('auth-section').classList.remove('hidden');
}
async function copyAddress() {
try {
await navigator.clipboard.writeText(address);
toast('Indirizzo copiato.', 'success');
} catch (e) {
toast('Impossibile copiare automaticamente.', 'error');
}
}
async function refreshMe() {
const btn = document.getElementById('refresh-btn');
await withLoading(btn, '…', async () => {
try {
const data = await call('GET', '/users/me');
document.getElementById('dash-balance').textContent = data.balance_sats / SATS_PER_PLM;
} catch (e) {
toast(e.message, 'error');
}
});
}
async function placeBet() {
const btn = document.getElementById('bet-btn');
await withLoading(btn, 'Invio bet…', async () => {
try {
const data = await call('POST', '/bets', {});
toast('Bet piazzata sul round #' + data.round_id + '.', 'success');
} catch (e) {
toast(e.message, 'error');
}
});
refreshMe();
refreshRound();
}
async function withdraw() {
const btn = document.getElementById('withdraw-btn');
const ext = document.getElementById('wd-address').value;
const amtPlm = parseFloat(document.getElementById('wd-amount').value);
const amtSats = Math.round(amtPlm * SATS_PER_PLM);
await withLoading(btn, 'Invio…', async () => {
try {
await call('POST', '/withdrawals', { external_address: ext, amount_sats: amtSats });
toast('Withdrawal inviato.', 'success');
} catch (e) {
toast(e.message, 'error');
}
});
refreshMe();
}
if (token) showDashboard();
</script>
</body>
</html>
View File
+163
View File
@@ -0,0 +1,163 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from embit import script
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from embit.finalizer import finalize_psbt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import settings
from app.db.models import PendingTransaction, User
from app.electrum.client import ElectrumClient
from app.wallet.hd import derive_pool_key, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 30
_FEE_RATE_INCREMENT = 1 # minimum relay-policy-friendly bump per BIP125
class RbfError(Exception):
pass
def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int | None = None) -> bool:
"""Pure decision: has this pending tx been unconfirmed for longer than the
configured timeout? Kept separate from the I/O-heavy bump_fee() so it's
trivially unit-testable."""
timeout = timeout_seconds if timeout_seconds is not None else settings.rbf_timeout_seconds
if pending.status != "pending":
return False
return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout)
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple:
"""Returns (signing_key, own_script, own_address) for the single sender that
controls every input of this tx — a user for bet/withdrawal, the pool for
payout. All our builders only ever spend one address's UTXOs per tx."""
if pending.kind == "payout":
key = derive_pool_key()
else:
user = await session.get(User, pending.user_id)
key = derive_user_key(user.derivation_index)
own_script = script.p2wpkh(key.to_public())
own_address = own_script.address(network=PLM_MAINNET)
return key, own_script, own_address
async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int:
txid_hex = vin.txid.hex()
tx = await client.get_transaction(txid_hex, verbose=True)
value_coins = tx["vout"][vin.vout]["value"]
return round(value_coins * 100_000_000)
def _find_change_output(tx: Transaction, change_address: str) -> int | None:
for i, out in enumerate(tx.vout):
if out.script_pubkey.address(network=PLM_MAINNET) == change_address:
return i
return None
async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str:
"""Rebuild `pending`'s transaction with a higher fee (same inputs, same
recipient outputs, the extra fee taken from the change output) and
rebroadcast. Returns the new txid.
Only handles the common case: exactly one change output paying back to the
tx's own sender address, large enough to absorb the increase. If there's no
such output (e.g. an exact-amount bet with no change), this raises RbfError —
bumping such a tx would require selecting additional inputs, which isn't
implemented for the MVP; it needs manual operator intervention.
"""
old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex))
signing_key, own_script, own_address = await _signing_context(session, pending)
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
total_in = sum(input_amounts)
old_fee = total_in - sum(o.value for o in old_tx.vout)
new_fee_rate = pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT
new_fee = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) * new_fee_rate
fee_delta = new_fee - old_fee
if fee_delta <= 0:
fee_delta = _FEE_RATE_INCREMENT # already above the new target vsize*rate; bump by a token amount
change_index = _find_change_output(old_tx, own_address)
if change_index is None or old_tx.vout[change_index].value <= fee_delta:
raise RbfError(f"pending_transaction {pending.id}: no change output large enough to absorb a fee bump")
new_vout = list(old_tx.vout)
bumped_change = new_vout[change_index].value - fee_delta
new_vout[change_index] = TransactionOutput(bumped_change, new_vout[change_index].script_pubkey)
new_vin = [TransactionInput(v.txid, v.vout, sequence=RBF_SEQUENCE) for v in old_tx.vin]
new_tx = Transaction(vin=new_vin, vout=new_vout)
psbt = PSBT(new_tx)
for i, amount in enumerate(input_amounts):
psbt.inputs[i].witness_utxo = TransactionOutput(amount, own_script)
signed = psbt.sign_with(signing_key)
if signed != len(new_vin):
raise RuntimeError(f"expected {len(new_vin)} signatures, got {signed}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize bumped PSBT")
raw_hex = final_tx.serialize().hex()
new_txid = final_tx.txid().hex()
await client.broadcast(raw_hex)
pending.current_txid = new_txid
pending.raw_tx_hex = raw_hex
pending.fee_rate_sat_vb = new_fee_rate
pending.attempt_count += 1
pending.broadcast_at = datetime.now(timezone.utc)
await session.commit()
logger.info("bumped %s pending_transaction %s: %s -> %s", pending.kind, pending.id, pending.current_txid, new_txid)
return new_txid
class RbfBumper:
def __init__(self, session_factory: async_sessionmaker, get_client):
self._session_factory = session_factory
self._get_client = get_client
async def run(self) -> None:
while True:
client = self._get_client()
if client is not None:
try:
await self._tick(client)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("RBF bump tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _tick(self, client: ElectrumClient) -> None:
now = datetime.now(timezone.utc)
async with self._session_factory() as session:
candidates = (
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
).all()
due = [p for p in candidates if should_bump(p, now)]
for pending in due:
async with self._session_factory() as session:
row = await session.get(PendingTransaction, pending.id)
if row is None or row.status != "pending":
continue
try:
await bump_fee(session, client, row)
except RbfError:
logger.exception("could not bump pending_transaction %s", row.id)
except Exception:
logger.exception("unexpected error bumping pending_transaction %s", row.id)
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import logging
from collections.abc import Awaitable, Callable
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.models import PendingTransaction
from app.electrum.client import ElectrumClient
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 10
ConfirmationHandler = Callable[[AsyncSession, PendingTransaction], Awaitable[None]]
_handlers: dict[str, ConfirmationHandler] = {}
def register_handler(kind: str, handler: ConfirmationHandler) -> None:
"""Domain modules (bets, rounds, withdrawals) register here so this generic
poller can notify them when one of their outgoing txs gets its 1st
confirmation, without this module importing them directly."""
_handlers[kind] = handler
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
async with session_factory() as session:
pending = (
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
).all()
pending_ids = [p.id for p in pending]
confirmed = 0
for pending_id, txid, kind in [(p.id, p.current_txid, p.kind) for p in pending]:
tx = await client.get_transaction(txid, verbose=True)
if not tx or tx.get("confirmations", 0) < 1:
continue
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
if row is None or row.status != "pending":
continue
row.status = "confirmed"
handler = _handlers.get(kind)
if handler is not None:
await handler(session, row)
await session.commit()
confirmed += 1
return confirmed
class ConfirmationPoller:
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
self._session_factory = session_factory
self._get_client = get_client
async def run(self) -> None:
while True:
client = self._get_client()
if client is not None:
try:
await poll_once(self._session_factory, client)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("confirmation poll failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
+22
View File
@@ -0,0 +1,22 @@
import asyncio
from contextlib import asynccontextmanager
class UserLocks:
"""Per-user asyncio.Lock registry, shared by PLAY and WITHDRAW so a user can
never have a bet-build and a withdrawal-build in flight at once (both would
otherwise spend from the same UTXO set on the user's dedicated address).
Single-process-only by design (an in-memory dict of asyncio.Lock) — this is an
accepted MVP constraint; a multi-process deployment would need a DB or Redis
lock instead (e.g. a Postgres advisory lock).
"""
def __init__(self) -> None:
self._locks: dict[int, asyncio.Lock] = {}
@asynccontextmanager
async def acquire(self, user_id: int):
lock = self._locks.setdefault(user_id, asyncio.Lock())
async with lock:
yield
View File
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import User, UtxoEvent
async def recompute_balance(session: AsyncSession, user_id: int) -> int:
"""Source of truth: sum of this user's confirmed, unspent UTXOs. Updates and
returns the read-cache column (User.cached_balance_sats). Must be called
within the same transaction as whatever inserted/updated utxo_events rows."""
balance = await session.scalar(
select(func.sum(UtxoEvent.amount_sats)).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid.is_(None))
)
user = await session.get(User, user_id)
user.cached_balance_sats = balance or 0
return user.cached_balance_sats
+52
View File
@@ -0,0 +1,52 @@
import os
from embit import script
from embit.bip32 import HDKey
from app.config import settings
from app.wallet.keystore import decrypt_xprv, encrypt_xprv
from app.wallet.plm_network import ACCOUNT_PATH, PLM_MAINNET
_account_key: HDKey | None = None
def generate_master_key(overwrite: bool = False) -> None:
"""One-time ops bootstrap: create a random master seed, encrypt it, write it to
disk. Not exposed via any API endpoint — run manually before first launch."""
if os.path.exists(settings.master_key_path) and not overwrite:
raise FileExistsError(f"{settings.master_key_path} already exists")
root = HDKey.from_seed(os.urandom(32), version=PLM_MAINNET["xprv"])
with open(settings.master_key_path, "wb") as f:
f.write(encrypt_xprv(root.to_base58(version=PLM_MAINNET["xprv"])))
def _load_account_key() -> HDKey:
global _account_key
if _account_key is None:
with open(settings.master_key_path, "rb") as f:
token = f.read()
root = HDKey.from_base58(decrypt_xprv(token))
_account_key = root.derive(ACCOUNT_PATH)
return _account_key
def derive_user_key(derivation_index: int) -> HDKey:
return _load_account_key().derive(f"0/{derivation_index}")
def derive_user_address(derivation_index: int) -> str:
pub = derive_user_key(derivation_index).to_public()
return script.p2wpkh(pub).address(network=PLM_MAINNET)
def derive_pool_key() -> HDKey:
"""The "indirizzo padre" from the flowchart: all bets are sent here, and
payouts are signed with this key. Reserved on branch 1 of the account (branch 0
is user addresses), index 0 — not a spec requirement, an implementation choice
to keep it in the same encrypted master key rather than a separate secret."""
return _load_account_key().derive("1/0")
def derive_pool_address() -> str:
pub = derive_pool_key().to_public()
return script.p2wpkh(pub).address(network=PLM_MAINNET)
+11
View File
@@ -0,0 +1,11 @@
from cryptography.fernet import Fernet
from app.config import settings
def encrypt_xprv(xprv_base58: str) -> bytes:
return Fernet(settings.xprv_encryption_key).encrypt(xprv_base58.encode())
def decrypt_xprv(token: bytes) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token).decode()
+28
View File
@@ -0,0 +1,28 @@
"""PLM mainnet params for embit, verified against PalladiumWallet/src/Core/Chain/ChainProfiles.cs.
Threaded explicitly through every embit call via `network=PLM_MAINNET` rather than
registered globally, since this is a long-lived async server (embit has no
concept of "current network" beyond what you pass in).
"""
PLM_MAINNET = {
"name": "PLM Mainnet",
"wif": bytes([0x80]),
"p2pkh": bytes([55]),
"p2sh": bytes([5]),
"bech32": "plm",
"xprv": bytes.fromhex("0488ade4"),
"xpub": bytes.fromhex("0488b21e"),
"yprv": bytes.fromhex("049d7878"),
"ypub": bytes.fromhex("049d7cb2"),
"zprv": bytes.fromhex("04b2430c"),
"zpub": bytes.fromhex("04b24746"),
"Yprv": bytes.fromhex("0295b005"),
"Ypub": bytes.fromhex("0295b43f"),
"Zprv": bytes.fromhex("02aa7a99"),
"Zpub": bytes.fromhex("02aa7ed3"),
"bip32": 0,
}
BIP44_COIN_TYPE = 746
ACCOUNT_PATH = f"m/84h/{BIP44_COIN_TYPE}h/0h"
+181
View File
@@ -0,0 +1,181 @@
from dataclasses import dataclass
from embit import script
from embit.bip32 import HDKey
from embit.finalizer import finalize_psbt
from embit.psbt import PSBT
from embit.transaction import Transaction, TransactionInput, TransactionOutput
# Standard P2WPKH size estimates (vbytes): 10.5-byte overhead (version+counts+locktime+
# segwit marker/flag), ~68 vbytes per input, ~31 vbytes per output. Used to size the fee
# before signing (fee only needs to be "minimized ~1 sat/vB", not maximally precise).
_TX_OVERHEAD_VBYTES = 11
_P2WPKH_INPUT_VBYTES = 68
_P2WPKH_OUTPUT_VBYTES = 31
# BIP125 opt-in RBF: any sequence < 0xfffffffe signals replaceability. Set on every
# input we create so a stuck tx can later be fee-bumped (tx/broadcast.py, stage 9).
RBF_SEQUENCE = 0xFFFFFFFD
class InsufficientFundsError(Exception):
pass
@dataclass
class Utxo:
txid: str
vout: int
amount_sats: int
@dataclass
class BuiltTransaction:
raw_hex: str
txid: str
fee_sats: int
recipient_sats: int
change_sats: int
spent_utxos: list[Utxo]
def estimate_vsize(n_inputs: int, n_outputs: int) -> int:
return _TX_OVERHEAD_VBYTES + n_inputs * _P2WPKH_INPUT_VBYTES + n_outputs * _P2WPKH_OUTPUT_VBYTES
def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]:
"""Greedily select UTXOs (largest first, to minimize input count) covering
target_sats — the amount deducted from the sender's balance. The fee is paid
out of target_sats (see build_signed_transaction), not added on top of it."""
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
selected: list[Utxo] = []
total = 0
for utxo in ordered:
selected.append(utxo)
total += utxo.amount_sats
if total >= target_sats:
return selected, total
raise InsufficientFundsError("not enough confirmed balance to cover amount")
def build_signed_transaction(
*,
signing_key: HDKey,
from_script: script.Script,
utxos: list[Utxo],
to_address: str,
amount_sats: int,
change_address: str,
fee_rate_sat_vb: int,
) -> BuiltTransaction:
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
back to change_address.
`amount_sats` is deducted from the sender's balance in full: the recipient
receives `amount_sats - fee`, change = total_in - amount_sats. This matches the
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
from the amount being moved", not paid on top by the sender.
"""
selected, total_in = select_utxos(utxos, amount_sats)
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
recipient_amount = amount_sats - fee
if recipient_amount <= 0:
raise InsufficientFundsError("amount too small to cover the network fee")
change = total_in - amount_sats
# TransactionInput.txid is natural/display byte order (as in tx_hash from Electrum);
# embit reverses it internally when serializing to wire format.
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [TransactionOutput(recipient_amount, script.Script.from_address(to_address))]
if change > 0:
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, utxo in enumerate(selected):
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
signed_count = psbt.sign_with(signing_key)
if signed_count != len(selected):
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize PSBT")
raw = final_tx.serialize()
return BuiltTransaction(
raw_hex=raw.hex(),
txid=final_tx.txid().hex(),
fee_sats=fee,
recipient_sats=recipient_amount,
change_sats=change,
spent_utxos=selected,
)
@dataclass
class PayoutTransaction:
raw_hex: str
txid: str
fee_sats: int
winner_sats: int
commission_sats: int
change_sats: int
spent_utxos: list[Utxo]
def build_payout_transaction(
*,
signing_key: HDKey,
from_script: script.Script,
utxos: list[Utxo],
winner_address: str,
winner_share_sats: int,
fee_address: str,
commission_sats: int,
change_address: str,
fee_rate_sat_vb: int,
) -> PayoutTransaction:
"""Build, sign and finalize the round payout: pool -> winner + fee address,
with change back to the pool itself. Per spec, only the winner's share
absorbs the tx fee — the commission (fee_address) output is untouched."""
target = winner_share_sats + commission_sats
selected, total_in = select_utxos(utxos, target)
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
winner_amount = winner_share_sats - fee
if winner_amount <= 0:
raise InsufficientFundsError("winner share too small to cover the network fee")
change = total_in - target
vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected]
vout = [
TransactionOutput(winner_amount, script.Script.from_address(winner_address)),
TransactionOutput(commission_sats, script.Script.from_address(fee_address)),
]
if change > 0:
vout.append(TransactionOutput(change, script.Script.from_address(change_address)))
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, utxo in enumerate(selected):
psbt.inputs[i].witness_utxo = TransactionOutput(utxo.amount_sats, from_script)
signed_count = psbt.sign_with(signing_key)
if signed_count != len(selected):
raise RuntimeError(f"expected {len(selected)} signatures, got {signed_count}")
final_tx = finalize_psbt(psbt)
if final_tx is None:
raise RuntimeError("failed to finalize PSBT")
raw = final_tx.serialize()
return PayoutTransaction(
raw_hex=raw.hex(),
txid=final_tx.txid().hex(),
fee_sats=fee,
winner_sats=winner_amount,
commission_sats=commission_sats,
change_sats=change,
spent_utxos=selected,
)
View File
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingTransaction, Withdrawal
from app.tx.confirmation import register_handler
async def _on_withdrawal_confirmed(session: AsyncSession, pending: PendingTransaction) -> None:
if pending.withdrawal_id is None:
return
withdrawal = await session.get(Withdrawal, pending.withdrawal_id)
if withdrawal is not None and withdrawal.status == "broadcast":
withdrawal.status = "confirmed"
withdrawal.confirmed_at = datetime.now(timezone.utc)
register_handler("withdrawal", _on_withdrawal_confirmed)
+86
View File
@@ -0,0 +1,86 @@
from embit import script
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.config import settings
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
from app.electrum.client import ElectrumClient
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction
class WithdrawalError(Exception):
pass
async def request_withdrawal(
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
) -> Withdrawal:
if amount_sats < settings.min_amount_sats:
raise WithdrawalError(f"amount below the minimum of {settings.min_amount_sats} sats")
unspent = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
)
).all()
if sum(u.amount_sats for u in unspent) < amount_sats:
raise WithdrawalError("insufficient balance")
user_key = derive_user_key(user.derivation_index)
from_script = script.p2wpkh(user_key.to_public())
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
try:
built = build_signed_transaction(
signing_key=user_key,
from_script=from_script,
utxos=utxos,
to_address=external_address,
amount_sats=amount_sats,
change_address=user.address,
fee_rate_sat_vb=settings.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise WithdrawalError(str(exc)) from exc
await client.broadcast(built.raw_hex)
spent_by_key = {(u.txid, u.vout): u for u in unspent}
for spent in built.spent_utxos:
spent_by_key[(spent.txid, spent.vout)].spent_txid = built.txid
await recompute_balance(session, user.id)
withdrawal = Withdrawal(
user_id=user.id,
external_address=external_address,
amount_requested_sats=amount_sats,
amount_sent_sats=built.recipient_sats,
txid=built.txid,
status="broadcast",
)
session.add(withdrawal)
await session.flush()
session.add(
PendingTransaction(
kind="withdrawal",
withdrawal_id=withdrawal.id,
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=settings.fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
)
)
await write_audit_log(
session,
"withdrawal_sent",
{"txid": built.txid, "amount_sent_sats": built.recipient_sats, "external_address": external_address},
user_id=user.id,
)
await session.commit()
await session.refresh(withdrawal)
return withdrawal
+33
View File
@@ -0,0 +1,33 @@
services:
app:
build: .
restart: unless-stopped
env_file: .env
environment:
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
volumes:
- ./data/db:/app/db_data
- ./data/keys:/app/key_data
- ./data/logs:/app/logs
expose:
- "8123"
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SITE_ADDRESS=${SITE_ADDRESS:-localhost}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
caddy_data:
caddy_config:
+61
View File
@@ -0,0 +1,61 @@
# Guida admin
Come gestire la configurazione operativa di PLM Lottery. Presuppone che il
server sia già avviato — vedi [running-the-server.md](running-the-server.md).
## Accesso
Il pannello admin è su **`https://<host>/admin`** — **non è collegato** da
nessun link nell'interfaccia utente (né in entrata né in uscita): ci si
arriva solo conoscendo l'URL. Non è protetto da login personale, ma da un
**token condiviso** (`ADMIN_TOKEN`, definito in `.env`).
Apri la pagina, incolla il valore di `ADMIN_TOKEN` nel campo "Admin token" e
usa i bottoni:
- **"Carica configurazione attuale"** → mostra `fee_address` e
`bet_amount_sats` (in PLM) correnti
- **"Salva"** → aggiorna i valori nel database, effetto immediato, nessun
riavvio del server necessario
## Cosa si configura
| Campo | Significato |
|---|---|
| **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. |
| **Bet amount (PLM)** | Il costo fisso d'ingresso per round, mostrato/impostato in PLM (internamente il backend lavora in sats: 1 PLM = 100.000.000 sats). |
`ROUND_DURATION_SECONDS` (durata del round) e `ROUND_COOLDOWN_SECONDS` (pausa
tra un round e il successivo, default 30s) **non** sono qui: sono variabili
d'ambiente in `.env`, non modificabili a runtime — per cambiarle serve
riavviare il server con il nuovo valore.
## Alternative all'interfaccia grafica
Le stesse operazioni si possono fare da terminale o da Swagger UI
(`https://<host>/docs`, sezione `admin`), sempre passando `ADMIN_TOKEN`
nell'header `X-Admin-Token`:
```bash
# leggere la configurazione
curl https://<host>/admin/config -H "X-Admin-Token: <ADMIN_TOKEN>"
# aggiornarla (importi in sats: 10 PLM = 1000000000)
curl -X PUT https://<host>/admin/config \
-H "X-Admin-Token: <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"fee_address": "plm1q...", "bet_amount_sats": 1000000000}'
```
## Limiti noti
- Il token è unico e condiviso: non c'è identità per singolo admin né audit
di chi ha cambiato cosa (oltre alla tabella `audit_log` generica).
- Nessun rate limiting sugli endpoint admin (né su registrazione/bet/
prelievo utente).
- Se un payout fallisce (es. Electrum disconnesso, UTXO insufficienti), il
round resta bloccato in `paying_out` senza retry automatico — richiede
intervento manuale.
Per l'elenco completo dei gap noti vedi la sezione "Known gaps / TODO" in
`CLAUDE.md`.
+68
View File
@@ -0,0 +1,68 @@
# Guida utente
Come usare PLM Lottery dall'interfaccia web (`https://<host>/` — vedi
[running-the-server.md](running-the-server.md) per come avviare il server).
## Registrazione e accesso
Nella schermata iniziale trovi due tab: **Registrati** e **Login**.
- **Registrati**: scegli username e password. Al termine ti viene assegnato
automaticamente un **indirizzo di deposito personale** (derivato
server-side) — è per sempre tuo, e riceverai anche eventuali vincite su
quello stesso indirizzo.
- **Login**: se hai già un account, accedi con username e password.
La sessione resta salvata nel browser (fino al logout): non serve rifare
login ogni volta che riapri la pagina.
## La dashboard
Dopo l'accesso vedi, in ordine:
1. **Barra account** — il tuo username e il bottone "Esci" (logout)
2. **Card del round corrente** — sempre visibile, indipendentemente dalla
sezione che stai guardando:
- numero del round e stato (*aperto*, *in chiusura*, *estrazione in
corso*, *pagamento in corso*)
- **timer** che conta alla rovescia il tempo rimanente prima della
chiusura del round
- **giocatori**: quanti hanno già piazzato una bet in questo round
- **jackpot**: il totale in PLM che verrà distribuito (70% al vincitore,
30% in fee)
3. **Menu di navigazione** con tre sezioni:
### Deposito
- Il tuo **saldo interno** (accreditato dopo 1 conferma di rete) con bottone
"Aggiorna" per ricontrollarlo
- Il tuo **indirizzo di deposito**, con bottone per copiarlo negli appunti
- Il **QR code** dello stesso indirizzo, comodo per inviare PLM da un altro
wallet scansionandolo invece di copiare l'indirizzo a mano
Per depositare, invia PLM (mainnet reale) a quell'indirizzo da un wallet
esterno. Il saldo si aggiorna da solo dopo la prima conferma; premi
"Aggiorna" per vederlo comparire.
### Bet
Un bottone unico: piazza l'ingresso a costo fisso (mostrato in PLM) nel round
corrente. Puoi avere **al massimo una bet attiva alla volta**. Il costo viene
scalato dal tuo saldo interno.
### Prelievo
Form con due campi:
- **Indirizzo esterno**: dove vuoi ricevere i PLM
- **Importo (PLM)**: quanto prelevare
Il prelievo viene costruito e trasmesso sulla rete; la fee di rete viene
scalata dall'importo richiesto (non si aggiunge separatamente).
## Notifiche
Ogni azione (registrazione, login, bet, prelievo, ecc.) mostra un breve
messaggio (toast) verde in caso di successo o rosso in caso di errore, in
basso nella pagina. Se qualcosa non va e il messaggio non basta a capire il
motivo, il dettaglio tecnico è nei log del server (`logs/app.log` o
`data/logs/app.log` con Docker) — non nell'interfaccia.
+70
View File
@@ -0,0 +1,70 @@
# Avviare il server
Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto,
master key generata, migrazioni applicate).
## Locale / venv (sviluppo rapido)
```bash
source .venv/bin/activate
uvicorn app.main:app --reload --port 8123
```
- App su `http://127.0.0.1:8123/`
- Pannello admin su `http://127.0.0.1:8123/admin`
- Log applicativi in `logs/app.log` (rotante, 10MB × 5 backup)
- Nessun TLS, nessun reverse proxy — solo per test locali sulla tua macchina.
Per fermarlo: `Ctrl+C`, oppure se lanciato in background con `nohup`:
```bash
pkill -f "uvicorn app.main:app"
```
## Docker + Caddy (consigliato, anche per i test con dominio/TLS)
```bash
mkdir -p data/db data/keys data/logs # una tantum, se non già presenti
docker compose up -d --build
```
- Caddy fa da reverse proxy davanti all'app e gestisce il TLS automaticamente
- App su `https://localhost/` (o sul dominio configurato, vedi sotto)
- Pannello admin su `https://localhost/admin`
- DB, master key cifrata e log persistono in `./data/` sulla root del repo
(bind mount, non volumi Docker opachi) — sopravvivono a stop/rebuild del
container e sono ispezionabili/backup-abili direttamente
### Modalità dev, senza dominio (certificato self-signed)
Non serve fare nulla: lasciando `SITE_ADDRESS` non impostata, Caddy usa
`localhost` di default. Rilevando che non è un hostname pubblico, genera da
solo un certificato dalla sua CA interna — il browser mostrerà un avviso di
sicurezza al primo accesso (normale, accettalo o usa `curl -k`).
### Modalità produzione, con dominio reale
```bash
SITE_ADDRESS=lottery.tuodominio.it docker compose up -d
```
Il DNS del dominio deve già puntare all'IP del server, con le porte 80 e 443
raggiungibili da internet. Caddy richiede e rinnova automaticamente un
certificato Let's Encrypt reale — nessuna configurazione aggiuntiva.
### Comandi utili
```bash
docker compose logs -f app # segui i log dell'app (anche in ./data/logs/app.log)
docker compose ps # stato dei container
docker compose stop # ferma senza rimuovere i container
docker compose down # ferma e rimuove i container (i dati in ./data/ restano)
```
### ⚠️ Attenzione: riavvii automatici a metà round
`docker-compose.yml` imposta `restart: unless-stopped` sul container dell'app:
se crasha, riparte da solo. Questo però non è ancora sicuro in ogni caso — se
il crash avviene mentre un round è in stato `closing`/`drawing`/`paying_out`,
lo scheduler non lo riprende al riavvio e il round resta bloccato (gap noto,
vedi "Known gaps" in `CLAUDE.md`). Non trattare questo setup come
"non supervisionato" finché quel gap non è risolto.
+77
View File
@@ -0,0 +1,77 @@
# Setup
Passaggi da eseguire una tantum per preparare un'istanza di PLM Lottery, prima di
poterla avviare (in locale o via Docker). Per come avviarla poi ogni volta, vedi
[running-the-server.md](running-the-server.md).
## 1. Prerequisiti
- Python 3.12+ (serve solo per il workflow locale/venv — puoi saltarlo se usi solo Docker)
- Docker + Docker Compose (serve solo per il workflow a container)
- Un server Electrum raggiungibile per la rete PLM. Il server di bootstrap per lo
sviluppo è `santantonio.sytes.net:50002` (SSL) — va bene per i test, ma in
produzione conviene usarne uno di cui ci si fida o gestirne uno proprio.
## 2. Creare il file `.env`
Copia `.env.example` in `.env` e compila i segreti. Ogni valore sotto viene
generato una volta e non cambia più (ruotarlo invalida sessioni/dati cifrati
esistenti):
```bash
cp .env.example .env
```
| Variabile | Scopo | Come generarla |
|---|---|---|
| `XPRV_ENCRYPTION_KEY` | Chiave simmetrica che cifra a riposo la master xprv del server. **Perdere questa chiave significa perdere per sempre l'accesso ai fondi di tutti gli utenti.** | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` |
| `JWT_SECRET` | Firma i token di sessione degli utenti. | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
| `ADMIN_TOKEN` | Token bearer richiesto sugli endpoint admin (header `X-Admin-Token`). | `python -c "import secrets; print(secrets.token_urlsafe(32))"` |
Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`,
`MASTER_KEY_PATH`, `ROUND_DURATION_SECONDS`) hanno default sensati in
`.env.example` — modificali se serve (es. round più corti per i test).
**Non committare mai `.env`.** È già escluso da `.gitignore`.
## 3. Generare la master key
Il server deriva l'indirizzo di deposito di ogni utente (e l'indirizzo pool) da
un'unica master xprv, generata una volta e cifrata a riposo con
`XPRV_ENCRYPTION_KEY`. Questo passaggio va eseguito esattamente una volta per
ogni deployment, dopo aver impostato `XPRV_ENCRYPTION_KEY` in `.env`:
- **Locale/venv**: `PYTHONPATH=. python scripts/generate_master_key.py`
- **Docker**: `docker compose run --rm app python scripts/generate_master_key.py`
Questo scrive un file cifrato (`MASTER_KEY_PATH`, default `./master.xprv.enc` in
locale o `./data/keys/master.xprv.enc` con Docker). **Fai il backup di questo
file insieme a `XPRV_ENCRYPTION_KEY`** — uno dei due da solo è inutile, ma
perderli entrambi insieme significa perdere i fondi di tutti gli utenti senza
possibilità di recupero.
## 4. Installare le dipendenze (solo workflow locale/venv)
Salta questo passaggio se usi solo Docker — l'immagine installa le proprie
dipendenze durante la build.
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## 5. Applicare le migrazioni del database
- **Locale/venv**: `alembic upgrade head`
- **Docker**: le migrazioni vengono eseguite automaticamente all'avvio del
container (vedi il `CMD` del `Dockerfile`) — nessun passaggio manuale.
## 6. Impostare l'indirizzo delle fee
Prima che il primo round possa pagare, un admin deve impostare `fee_address`
tramite il pannello admin o l'API — vedi [admin-guide.md](admin-guide.md). I
payout si rifiutano di partire finché non è impostato.
A questo punto l'istanza è pronta per essere avviata — continua con
[running-the-server.md](running-the-server.md).
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.
+85
View File
@@ -0,0 +1,85 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.config import settings
from app.db.base import Base
from app.db import models # noqa: F401 (registers models on Base.metadata)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.database_url)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,153 @@
"""initial schema
Revision ID: 274efdcbfbcc
Revises:
Create Date: 2026-07-20 22:11:47.766165
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '274efdcbfbcc'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('round_config',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('fee_address', sa.String(length=128), nullable=False),
sa.Column('bet_amount_sats', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('password_hash', sa.String(length=256), nullable=False),
sa.Column('derivation_index', sa.Integer(), nullable=False),
sa.Column('address', sa.String(length=128), nullable=False),
sa.Column('cached_balance_sats', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('address'),
sa.UniqueConstraint('derivation_index')
)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('rounds',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('opened_at', sa.DateTime(), nullable=False),
sa.Column('closed_at', sa.DateTime(), nullable=True),
sa.Column('draw_block_height', sa.Integer(), nullable=True),
sa.Column('draw_block_hash', sa.String(length=64), nullable=True),
sa.Column('seed_int', sa.String(length=128), nullable=True),
sa.Column('winner_user_id', sa.Integer(), nullable=True),
sa.Column('pool_amount_sats', sa.BigInteger(), nullable=True),
sa.Column('winner_amount_sats', sa.BigInteger(), nullable=True),
sa.Column('fee_amount_sats', sa.BigInteger(), nullable=True),
sa.Column('payout_txid', sa.String(length=64), nullable=True),
sa.ForeignKeyConstraint(['winner_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('utxo_events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('txid', sa.String(length=64), nullable=False),
sa.Column('vout', sa.Integer(), nullable=False),
sa.Column('amount_sats', sa.BigInteger(), nullable=False),
sa.Column('confirmed_height', sa.Integer(), nullable=False),
sa.Column('confirmed_at', sa.DateTime(), nullable=False),
sa.Column('spent_txid', sa.String(length=64), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('txid', 'vout')
)
op.create_index(op.f('ix_utxo_events_user_id'), 'utxo_events', ['user_id'], unique=False)
op.create_table('withdrawals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('external_address', sa.String(length=128), nullable=False),
sa.Column('amount_requested_sats', sa.BigInteger(), nullable=False),
sa.Column('amount_sent_sats', sa.BigInteger(), nullable=True),
sa.Column('txid', sa.String(length=64), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_withdrawals_user_id'), 'withdrawals', ['user_id'], unique=False)
op.create_table('audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_type', sa.String(length=32), nullable=False),
sa.Column('payload_json', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('round_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['round_id'], ['rounds.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('pending_transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=16), nullable=False),
sa.Column('round_id', sa.Integer(), nullable=True),
sa.Column('withdrawal_id', sa.Integer(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('current_txid', sa.String(length=64), nullable=False),
sa.Column('fee_rate_sat_vb', sa.Integer(), nullable=False),
sa.Column('raw_tx_hex', sa.String(), nullable=False),
sa.Column('broadcast_at', sa.DateTime(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('replaced_by_txid', sa.String(length=64), nullable=True),
sa.Column('attempt_count', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['round_id'], ['rounds.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['withdrawal_id'], ['withdrawals.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('round_participants',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('round_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('bet_amount_sats', sa.BigInteger(), nullable=False),
sa.Column('bet_txid', sa.String(length=64), nullable=False),
sa.Column('broadcast_at', sa.DateTime(), nullable=False),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.ForeignKeyConstraint(['round_id'], ['rounds.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('round_id', 'user_id')
)
op.create_index(op.f('ix_round_participants_round_id'), 'round_participants', ['round_id'], unique=False)
op.create_index(op.f('ix_round_participants_user_id'), 'round_participants', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_round_participants_user_id'), table_name='round_participants')
op.drop_index(op.f('ix_round_participants_round_id'), table_name='round_participants')
op.drop_table('round_participants')
op.drop_table('pending_transactions')
op.drop_table('audit_log')
op.drop_index(op.f('ix_withdrawals_user_id'), table_name='withdrawals')
op.drop_table('withdrawals')
op.drop_index(op.f('ix_utxo_events_user_id'), table_name='utxo_events')
op.drop_table('utxo_events')
op.drop_table('rounds')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_table('users')
op.drop_table('round_config')
# ### end Alembic commands ###
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "plm-lottery"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy>=2.0",
"aiosqlite>=0.20",
"alembic>=1.13",
"pydantic-settings>=2.6",
"argon2-cffi>=23.1",
"pyjwt>=2.7",
"cryptography>=43.0",
"embit>=0.7",
"qrcode[pil]>=7.4",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.setuptools.packages.find]
include = ["app*"]
+27
View File
@@ -0,0 +1,27 @@
"""Manual smoke test: connect to the configured Electrum server, do the
server.version handshake, subscribe to headers and confirm a sane tip height.
Usage: PYTHONPATH=. python scripts/electrum_smoke_test.py
"""
import asyncio
from app.config import settings
from app.electrum.client import ElectrumClient
async def main() -> None:
client = ElectrumClient(settings.electrum_host, settings.electrum_port, settings.electrum_use_ssl)
await client.connect()
print("connected + handshake OK")
header = await client.subscribe_headers()
print("tip:", header)
assert header["height"] > 0
await client.close()
print("OK")
if __name__ == "__main__":
asyncio.run(main())
+16
View File
@@ -0,0 +1,16 @@
"""One-time ops bootstrap: generate and encrypt the server's master xprv.
Usage:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# put that value in XPRV_ENCRYPTION_KEY (.env), then:
python scripts/generate_master_key.py
"""
from app.config import settings
from app.wallet.hd import generate_master_key
if __name__ == "__main__":
if not settings.xprv_encryption_key:
raise SystemExit("XPRV_ENCRYPTION_KEY is not set")
generate_master_key()
print(f"Master key written (encrypted) to {settings.master_key_path}")
View File
View File
View File
+64
View File
@@ -0,0 +1,64 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
from sqlalchemy.ext.asyncio import create_async_engine
from app.db import base as db_base
db_base.engine = create_async_engine(settings.database_url)
from sqlalchemy.ext.asyncio import async_sessionmaker
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from app.api.routes.admin import router as admin_router
from fastapi import FastAPI
app = FastAPI()
app.include_router(admin_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def test_admin_requires_token(client):
resp = await client.get("/admin/config")
assert resp.status_code == 403
async def test_admin_rejects_wrong_token(client):
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
assert resp.status_code == 403
async def test_admin_reads_and_updates_config(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/config", headers=headers)
assert resp.status_code == 200
assert resp.json()["fee_address"] == ""
resp = await client.put(
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
)
assert resp.status_code == 200
body = resp.json()
assert body["fee_address"] == "plm1qfeeaddress"
assert body["bet_amount_sats"] == 500_000_000
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["fee_address"] == "plm1qfeeaddress"
+106
View File
@@ -0,0 +1,106 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent
from app.wallet.hd import derive_user_address
class FakeElectrumClient:
def __init__(self):
self.broadcasted: list[str] = []
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "fake-network-txid"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(settings, "xprv_encryption_key", __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode())
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
async with session_factory() as session:
address = derive_user_address(index)
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(
user_id=user.id,
txid=f"{index:02x}" * 32,
vout=0,
amount_sats=funded_sats,
confirmed_height=100,
)
)
await session.commit()
return user.id
async def test_place_bet_broadcasts_and_records_participant(session_factory):
user_id = await _make_funded_user(session_factory, 0, 1_500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
participant = await place_bet(session, client, user)
assert client.broadcasted # a raw tx was broadcast
assert participant.status == "broadcast"
assert participant.bet_txid
async with session_factory() as session:
utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one()
assert utxo.spent_txid == participant.bet_txid
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "bet"
audit_events = (await session.scalars(select(AuditLog))).all()
assert any(e.event_type == "bet_placed" for e in audit_events)
assert pending.current_txid == participant.bet_txid
async def test_place_bet_rejects_insufficient_balance(session_factory):
user_id = await _make_funded_user(session_factory, 1, 1_000_000) # below bet_amount_sats
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="insufficient balance"):
await place_bet(session, client, user)
async def test_place_bet_rejects_second_bet_same_round(session_factory):
user_id = await _make_funded_user(session_factory, 2, 3_000_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
await place_bet(session, client, user)
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="already"):
await place_bet(session, client, user)
async with session_factory() as session:
participants = (await session.scalars(select(RoundParticipant))).all()
assert len(participants) == 1
+181
View File
@@ -0,0 +1,181 @@
from datetime import datetime, timedelta, timezone
import pytest
from embit import script
from embit.bip32 import HDKey
from embit.transaction import Transaction
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User
from app.tx.broadcast import RbfError, bump_fee, should_bump
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import Utxo, build_signed_transaction
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_should_bump_false_before_timeout():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
def test_should_bump_true_after_timeout():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True
def test_should_bump_false_when_not_pending():
pending = PendingTransaction(
kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False
class FakeClient:
def __init__(self, prevout_values: dict[str, int]):
self._prevout_values = prevout_values
self.broadcasted: list[str] = []
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}}
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "network-txid"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory):
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(99).to_public()).address(network=PLM_MAINNET)
utxo_amount = 150_000_000
utxo_txid = "11" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="alice", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
new_txid = await bump_fee(session, client, row)
assert client.broadcasted
assert new_txid != built.txid
new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0]))
old_tx = Transaction.parse(bytes.fromhex(built.raw_hex))
old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address)
assert new_change < old_change # fee bump came out of the change output
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
assert row.current_txid == new_txid
assert row.fee_rate_sat_vb == 2
assert row.attempt_count == 2
async def test_bump_fee_raises_when_no_change_output(session_factory):
from app.wallet.hd import derive_user_address, derive_user_key
signer = derive_user_key(0)
my_address = derive_user_address(0)
from_script = script.p2wpkh(signer.to_public())
to_address = script.p2wpkh(_key(98).to_public()).address(network=PLM_MAINNET)
utxo_amount = 10_000_000 # exact amount, no change output
utxo_txid = "22" * 32
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=[Utxo(utxo_txid, 0, utxo_amount)],
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
async with session_factory() as session:
user = User(username="bob", password_hash="x", derivation_index=0, address=my_address)
session.add(user)
await session.commit()
pending = PendingTransaction(
kind="bet",
user_id=user.id,
current_txid=built.txid,
fee_rate_sat_vb=1,
raw_tx_hex=built.raw_hex,
status="pending",
broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000),
)
session.add(pending)
await session.commit()
pending_id = pending.id
client = FakeClient({utxo_txid: utxo_amount})
async with session_factory() as session:
row = await session.get(PendingTransaction, pending_id)
with pytest.raises(RbfError):
await bump_fee(session, client, row)
+82
View File
@@ -0,0 +1,82 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.bets.confirmation # noqa: F401 (registers the "bet" handler)
import app.rounds.confirmation # noqa: F401 (registers the "payout" handler)
from app.db.base import Base
from app.db.models import PendingTransaction, Round, RoundParticipant
from app.tx.confirmation import poll_once
class FakeClient:
def __init__(self, confirmations_by_txid: dict[str, int]):
self._confirmations = confirmations_by_txid
async def get_transaction(self, txid: str, verbose: bool = False) -> dict:
return {"confirmations": self._confirmations.get(txid, 0)}
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def test_bet_confirmation_marks_participant_confirmed(session_factory):
async with session_factory() as session:
session.add(Round(id=1, status="open"))
session.add(
RoundParticipant(
round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast"
)
)
session.add(
PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")
)
await session.commit()
client = FakeClient({"tx1": 1})
confirmed = await poll_once(session_factory, client)
assert confirmed == 1
async with session_factory() as session:
participant = (await session.scalars(select(RoundParticipant))).one()
assert participant.status == "confirmed"
assert participant.confirmed_at is not None
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.status == "confirmed"
async def test_unconfirmed_tx_is_left_pending(session_factory):
async with session_factory() as session:
session.add(Round(id=2, status="open"))
session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast"))
session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
await session.commit()
client = FakeClient({"tx2": 0})
confirmed = await poll_once(session_factory, client)
assert confirmed == 0
async with session_factory() as session:
participant = (await session.scalars(select(RoundParticipant))).one()
assert participant.status == "broadcast"
async def test_payout_confirmation_closes_round(session_factory):
async with session_factory() as session:
session.add(Round(id=3, status="paying_out", payout_txid="tx3"))
session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending"))
await session.commit()
client = FakeClient({"tx3": 2})
confirmed = await poll_once(session_factory, client)
assert confirmed == 1
async with session_factory() as session:
round_ = await session.get(Round, 3)
assert round_.status == "closed"
+54
View File
@@ -0,0 +1,54 @@
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import User
from app.deposits.service import credit_confirmed_utxos
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
@pytest.fixture
async def user_id(session_factory):
async with session_factory() as session:
user = User(username="alice", password_hash="x", derivation_index=0, address="plm1qxxx")
session.add(user)
await session.commit()
return user.id
async def test_credits_confirmed_utxo_and_updates_balance(session_factory, user_id):
entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 10_000_000}]
async with session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
assert credited == 1
user = await session.get(User, user_id)
assert user.cached_balance_sats == 10_000_000
async def test_unconfirmed_entry_is_ignored(session_factory, user_id):
entries = [{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 5_000_000}]
async with session_factory() as session:
credited = await credit_confirmed_utxos(session, user_id, entries)
assert credited == 0
user = await session.get(User, user_id)
assert user.cached_balance_sats == 0
async def test_idempotent_on_repeated_notification(session_factory, user_id):
entries = [{"tx_hash": "cc" * 32, "tx_pos": 0, "height": 100, "value": 7_000_000}]
async with session_factory() as session:
first = await credit_confirmed_utxos(session, user_id, entries)
async with session_factory() as session:
second = await credit_confirmed_utxos(session, user_id, entries)
user = await session.get(User, user_id)
assert first == 1
assert second == 0
assert user.cached_balance_sats == 7_000_000
+32
View File
@@ -0,0 +1,32 @@
import pytest
from app.rounds.draw import draw_winner, header_hex_to_block_hash
def test_header_hex_to_block_hash_matches_known_mainnet_block():
# Real PLM mainnet block 477486: header from blockchain.block.header, hash
# cross-checked against the blockhash reported by blockchain.transaction.get
# for a tx confirmed in that block.
header_hex = (
"0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000"
"009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875"
"e6a47a61b1ad8012582"
)
known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86"
assert header_hex_to_block_hash(header_hex) == known_block_hash
def test_draw_winner_is_deterministic_and_within_range():
participants = ["addrA", "addrB", "addrC"]
block_hash = "00" * 31 + "05" # seed = 5, index = 5 % 3 = 2
assert draw_winner(participants, block_hash) == "addrC"
def test_draw_winner_single_participant_always_wins():
block_hash = "ff" * 32
assert draw_winner(["only"], block_hash) == "only"
def test_draw_winner_raises_on_empty_participants():
with pytest.raises(ValueError):
draw_winner([], "00" * 32)
+77
View File
@@ -0,0 +1,77 @@
import asyncio
import json
from app.electrum.client import ElectrumClient, ElectrumError
class FakeWriter:
def __init__(self):
self.written = b""
def write(self, data: bytes) -> None:
self.written += data
async def drain(self) -> None:
pass
def close(self) -> None:
pass
async def wait_closed(self) -> None:
pass
async def _client_with_fake_transport() -> tuple[ElectrumClient, asyncio.StreamReader, FakeWriter]:
client = ElectrumClient("localhost", 1234)
reader = asyncio.StreamReader()
writer = FakeWriter()
client._reader = reader
client._writer = writer
client._read_task = asyncio.create_task(client._read_loop())
return client, reader, writer
async def test_request_resolves_on_matching_response():
client, reader, writer = await _client_with_fake_transport()
task = asyncio.create_task(client.request("blockchain.headers.subscribe"))
await asyncio.sleep(0) # let request() write the payload
sent = json.loads(writer.written.decode())
assert sent["method"] == "blockchain.headers.subscribe"
reader.feed_data((json.dumps({"id": sent["id"], "result": {"height": 100}}) + "\n").encode())
result = await task
assert result == {"height": 100}
client._read_task.cancel()
async def test_error_response_raises_electrum_error():
client, reader, writer = await _client_with_fake_transport()
task = asyncio.create_task(client.request("blockchain.transaction.broadcast", ["deadbeef"]))
await asyncio.sleep(0)
sent = json.loads(writer.written.decode())
reader.feed_data((json.dumps({"id": sent["id"], "error": "bad tx"}) + "\n").encode())
try:
await task
assert False, "expected ElectrumError"
except ElectrumError:
pass
client._read_task.cancel()
async def test_notification_delivered_to_subscription_queue():
client, reader, writer = await _client_with_fake_transport()
queue = client.notifications("blockchain.scripthash.subscribe")
push = {"method": "blockchain.scripthash.subscribe", "params": ["abcd", "newstatus"]}
reader.feed_data((json.dumps(push) + "\n").encode())
await asyncio.sleep(0)
params = await asyncio.wait_for(queue.get(), timeout=1)
assert params == ["abcd", "newstatus"]
client._read_task.cancel()
+21
View File
@@ -0,0 +1,21 @@
from embit.bip32 import HDKey
from app.wallet.plm_network import PLM_MAINNET
def test_p2wpkh_address_uses_plm_hrp():
from embit import script
root = HDKey.from_seed(b"\x01" * 32, version=PLM_MAINNET["xprv"])
child = root.derive("m/84h/746h/0h/0/0")
address = script.p2wpkh(child.to_public()).address(network=PLM_MAINNET)
assert address.startswith("plm1q")
def test_derivation_is_deterministic():
root = HDKey.from_seed(b"\x02" * 32, version=PLM_MAINNET["xprv"])
a = root.derive("m/84h/746h/0h/0/5").sec()
b = root.derive("m/84h/746h/0h/0/5").sec()
c = root.derive("m/84h/746h/0h/0/6").sec()
assert a == b
assert a != c
+100
View File
@@ -0,0 +1,100 @@
import pytest
from embit import script
from embit.bip32 import HDKey
from embit.transaction import Transaction
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction, estimate_vsize
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_payout_deducts_fee_only_from_winner_share():
pool_key = _key(10)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(11).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(12).to_public()).address(network=PLM_MAINNET)
pool_amount = 10_000_000_000 # 100 PLM pot
winner_share = pool_amount * 70 // 100
commission = pool_amount - winner_share
utxos = [Utxo("aa" * 32, 0, pool_amount)]
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
fee = estimate_vsize(1, 3)
assert built.fee_sats == fee
assert built.winner_sats == winner_share - fee
assert built.commission_sats == commission # untouched by the fee
assert built.change_sats == pool_amount - (winner_share + commission)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 2 # no change needed: winner_share + commission == pool_amount exactly
amounts = sorted(o.value for o in parsed.vout)
assert amounts == sorted([built.winner_sats, built.commission_sats])
def test_payout_adds_change_output_when_pool_utxos_exceed_target():
pool_key = _key(20)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(21).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(22).to_public()).address(network=PLM_MAINNET)
winner_share = 700_000_000
commission = 300_000_000
utxos = [Utxo("bb" * 32, 0, 2_000_000_000)] # more than winner_share+commission
built = build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 2_000_000_000 - (winner_share + commission)
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 3
def test_payout_raises_when_winner_share_too_small():
pool_key = _key(30)
pool_script = script.p2wpkh(pool_key.to_public())
pool_address = pool_script.address(network=PLM_MAINNET)
winner_address = script.p2wpkh(_key(31).to_public()).address(network=PLM_MAINNET)
fee_address = script.p2wpkh(_key(32).to_public()).address(network=PLM_MAINNET)
winner_share = 100 # smaller than the ~172 sat fee at 1 sat/vB for 1-in-3-out
commission = 50
assert winner_share < estimate_vsize(1, 3)
utxos = [Utxo("cc" * 32, 0, winner_share + commission)]
with pytest.raises(InsufficientFundsError):
build_payout_transaction(
signing_key=pool_key,
from_script=pool_script,
utxos=utxos,
winner_address=winner_address,
winner_share_sats=winner_share,
fee_address=fee_address,
commission_sats=commission,
change_address=pool_address,
fee_rate_sat_vb=1,
)
+113
View File
@@ -0,0 +1,113 @@
import pytest
from embit import script
from embit.bip32 import HDKey
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
InsufficientFundsError,
Utxo,
build_signed_transaction,
estimate_vsize,
select_utxos,
)
def _key(seed_byte: int) -> HDKey:
root = HDKey.from_seed(bytes([seed_byte]) * 32, version=PLM_MAINNET["xprv"])
return root.derive("m/84h/746h/0h/0/0")
def test_estimate_vsize_grows_with_inputs_and_outputs():
assert estimate_vsize(1, 2) < estimate_vsize(2, 2)
assert estimate_vsize(1, 1) < estimate_vsize(1, 2)
def test_select_utxos_picks_largest_first():
utxos = [Utxo("a" * 64, 0, 5_000_000), Utxo("b" * 64, 0, 20_000_000), Utxo("c" * 64, 0, 1_000_000)]
selected, total = select_utxos(utxos, target_sats=10_000_000)
assert selected == [utxos[1]] # the 20M UTXO alone covers 10M
assert total == 20_000_000
def test_select_utxos_raises_when_insufficient():
utxos = [Utxo("a" * 64, 0, 1_000_000)]
with pytest.raises(InsufficientFundsError):
select_utxos(utxos, target_sats=10_000_000)
def test_build_signed_transaction_deducts_fee_from_amount_not_change():
signer = _key(1)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(2).to_public()).address(network=PLM_MAINNET)
utxos = [Utxo("11" * 32, 0, 150_000_000)]
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
fee = estimate_vsize(1, 2)
assert built.fee_sats == fee
assert built.recipient_sats == 10_000_000 - fee
# change reflects the full amount_sats deducted from the sender, fee comes out
# of what the recipient gets, not out of the sender's remaining balance
assert built.change_sats == 150_000_000 - 10_000_000
assert built.spent_utxos == utxos
assert len(built.txid) == 64
from embit.transaction import Transaction
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vin[0].witness.items) == 2
assert len(parsed.vout) == 2
def test_build_signed_transaction_omits_change_output_when_exact_amount():
signer = _key(3)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(4).to_public()).address(network=PLM_MAINNET)
utxos = [Utxo("22" * 32, 0, 10_000_000)] # exactly amount_sats, zero change
built = build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=10_000_000,
change_address=my_address,
fee_rate_sat_vb=1,
)
assert built.change_sats == 0
from embit.transaction import Transaction
parsed = Transaction.parse(bytes.fromhex(built.raw_hex))
assert len(parsed.vout) == 1
def test_build_signed_transaction_raises_when_amount_smaller_than_fee():
signer = _key(5)
from_script = script.p2wpkh(signer.to_public())
my_address = from_script.address(network=PLM_MAINNET)
to_address = script.p2wpkh(_key(6).to_public()).address(network=PLM_MAINNET)
small_amount = 100 # smaller than the ~141 sat fee at 1 sat/vB for 1-in-2-out
assert small_amount < estimate_vsize(1, 2)
utxos = [Utxo("33" * 32, 0, small_amount)]
with pytest.raises(InsufficientFundsError):
build_signed_transaction(
signing_key=signer,
from_script=from_script,
utxos=utxos,
to_address=to_address,
amount_sats=small_amount,
change_address=my_address,
fee_rate_sat_vb=1,
)
+82
View File
@@ -0,0 +1,82 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import Round
from app.rounds.service import get_active_round, open_new_round_if_needed
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def test_opens_a_round_when_none_exists(session_factory):
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
assert round_.status == "open"
async def test_reuses_existing_open_round(session_factory):
async with session_factory() as session:
first = await open_new_round_if_needed(session)
await session.commit()
first_id = first.id
async with session_factory() as session:
second = await open_new_round_if_needed(session)
assert second.id == first_id
@pytest.mark.parametrize("status", ["closing", "drawing", "paying_out"])
async def test_does_not_open_new_round_while_previous_is_in_progress(session_factory, status):
async with session_factory() as session:
session.add(Round(status=status))
await session.commit()
async with session_factory() as session:
active = await get_active_round(session)
assert active is not None
assert active.status == status
# open_new_round_if_needed must return the in-progress round, not open a new one
returned = await open_new_round_if_needed(session)
assert returned.status == status
async def test_opens_new_round_after_previous_is_closed(session_factory):
async with session_factory() as session:
session.add(Round(status="closed"))
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
assert round_.status == "open"
async def test_withholds_new_round_during_cooldown(session_factory):
async with session_factory() as session:
session.add(Round(status="closed", closed_at=datetime.now(timezone.utc)))
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
assert round_ is None
async def test_opens_new_round_once_cooldown_elapses(session_factory):
stale_close = datetime.now(timezone.utc) - timedelta(seconds=settings.round_cooldown_seconds + 1)
async with session_factory() as session:
session.add(Round(status="closed", closed_at=stale_close))
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
assert round_.status == "open"
+55
View File
@@ -0,0 +1,55 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import Round
from app.rounds.scheduler import RoundScheduler
class FakeListener:
client = object() # truthy sentinel; _tick only checks "is not None"
tip_height = 100
tip_header_hex = "00"
@pytest.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
async def test_tick_survives_sqlite_naive_datetime_roundtrip(session_factory, monkeypatch):
"""Regression test: SQLite drops tzinfo on round-trip, so opened_at comes back
naive even though it was written as an aware UTC datetime. A prior bug compared
it directly against datetime.now(timezone.utc) and crashed with
"can't compare offset-naive and offset-aware datetimes" on every tick once a
round existed — this must not happen."""
monkeypatch.setattr(settings, "round_duration_seconds", 3600) # not due yet
async with session_factory() as session:
session.add(Round(status="open", opened_at=datetime.now(timezone.utc)))
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
await scheduler._tick() # must not raise
async def test_tick_closes_round_with_no_participants_once_due(session_factory, monkeypatch):
monkeypatch.setattr(settings, "round_duration_seconds", 1)
past = datetime.now(timezone.utc) - timedelta(seconds=10)
async with session_factory() as session:
session.add(Round(status="open", opened_at=past))
await session.commit()
scheduler = RoundScheduler(session_factory, FakeListener())
await scheduler._tick()
async with session_factory() as session:
round_ = (await session.scalars(select(Round))).one()
assert round_.status == "closed"
+9
View File
@@ -0,0 +1,9 @@
from app.electrum.scripthash import address_to_scripthash
def test_matches_known_mainnet_vector():
# Ground truth: scriptPubKey "0014a195473740aea3b4df1690fbdcb51243fe4e7a20" of a real
# confirmed mainnet output to this address (txid 1ebd0219...b0d48a, vout 1), cross-checked
# against the Electrum server's own listunspent for this scripthash.
address = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
assert address_to_scripthash(address) == "5b7744e34b1d6ee3ae6eef7e1ce82aa4c28ff13f10d4aa905640c722c9249111"
+13
View File
@@ -0,0 +1,13 @@
from app.auth import security
def test_password_hash_roundtrip():
hashed = security.hash_password("s3cret!")
assert security.verify_password("s3cret!", hashed)
assert not security.verify_password("wrong", hashed)
def test_jwt_roundtrip(monkeypatch):
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
token = security.create_access_token(user_id=42)
assert security.decode_access_token(token) == 42
+98
View File
@@ -0,0 +1,98 @@
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
from app.db.base import Base
from app.db.models import PendingTransaction, User, UtxoEvent
from app.wallet.hd import derive_user_address
from app.withdrawals.service import WithdrawalError, request_withdrawal
class FakeElectrumClient:
def __init__(self):
self.broadcasted: list[str] = []
async def broadcast(self, raw_tx_hex: str) -> str:
self.broadcasted.append(raw_tx_hex)
return "fake-network-txid"
EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
@pytest.fixture
async def session_factory(tmp_path, monkeypatch):
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
monkeypatch.setattr(
settings,
"xprv_encryption_key",
__import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(),
)
from app.wallet import hd
hd._account_key = None
hd.generate_master_key()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield async_sessionmaker(engine, expire_on_commit=False)
await engine.dispose()
hd._account_key = None
async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int:
async with session_factory() as session:
address = derive_user_address(index)
user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address)
session.add(user)
await session.commit()
session.add(
UtxoEvent(user_id=user.id, txid=f"{index:02x}" * 32, vout=0, amount_sats=funded_sats, confirmed_height=100)
)
await session.commit()
return user.id
async def test_withdrawal_broadcasts_and_updates_balance(session_factory):
user_id = await _make_funded_user(session_factory, 0, 500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, 100_000_000)
assert client.broadcasted
assert withdrawal.status == "broadcast"
assert withdrawal.amount_sent_sats < 100_000_000 # fee deducted from the amount
async with session_factory() as session:
user = await session.get(User, user_id)
# The spent UTXO is gone immediately; the change output isn't credited
# until it's independently observed as confirmed on-chain (same as bets) —
# so the cached balance is transiently 0 until then, not the pre-fee delta.
assert user.cached_balance_sats == 0
pending = (await session.scalars(select(PendingTransaction))).one()
assert pending.kind == "withdrawal"
assert pending.withdrawal_id == withdrawal.id
async def test_withdrawal_rejects_amount_below_minimum(session_factory):
user_id = await _make_funded_user(session_factory, 1, 500_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="minimum"):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats - 1)
async def test_withdrawal_rejects_insufficient_balance(session_factory):
user_id = await _make_funded_user(session_factory, 2, 1_000_000)
client = FakeElectrumClient()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(WithdrawalError, match="insufficient balance"):
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats)