Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db24287ea5 | ||
|
|
82d078e2d6 | ||
|
|
50b68d2957 | ||
|
|
ca50ef62f3 | ||
|
|
a3c51719bd | ||
|
|
a4b160e240 | ||
|
|
f936d6660c | ||
|
|
2b6ddb019b | ||
|
|
13cb66cc06 | ||
|
|
fd6a91e93d | ||
|
|
cc63753439 | ||
|
|
daec4749c2 | ||
|
|
b424f670c5 | ||
|
|
7ba8e22bb4 | ||
|
|
4be58709d1 | ||
|
|
88221b0dbd | ||
|
|
61efbb0e37 | ||
|
|
526a649c8b | ||
|
|
666cb1a0c9 | ||
|
|
162ceed40f | ||
|
|
5f6abe5b32 | ||
|
|
23d58796b6 | ||
|
|
c4b2dc3ea2 | ||
|
|
c0314e2bf0 | ||
|
|
8a0ebecfcc | ||
|
|
8dd913ec59 | ||
|
|
37cc5eeeb5 | ||
|
|
77e07e87dc | ||
|
|
6246b13247 | ||
|
|
0aac73e557 | ||
|
|
57721355f0 | ||
|
|
ab65728bdc | ||
|
|
9c7befe595 | ||
|
|
421fe72a8b | ||
|
|
907e32e9e0 | ||
|
|
8f3cdcb2f8 | ||
|
|
64f62291d2 | ||
|
|
025754c860 | ||
|
|
99d7a1ee00 |
No files matched your search
+20
-5
@@ -3,17 +3,32 @@ ELECTRUM_PORT=50002
|
||||
ELECTRUM_USE_SSL=true
|
||||
|
||||
# Additional Electrum servers to fall back to, comma-separated. Each entry is
|
||||
# `host:port` (TLS, the normal case) or `host:port:notls`. The listener rotates
|
||||
# over the primary above plus these, so one unreachable server costs a single
|
||||
# reconnect attempt instead of an outage — every deposit credit, broadcast and
|
||||
# `host:port` (TLS, the normal case), `host:port:notls`, or `host:port:pin=<sha256>`
|
||||
# (TLS, and the server's DER certificate must hash to that SHA-256, in hex). The
|
||||
# listener rotates over the primary above plus these, so one unreachable server costs
|
||||
# a single reconnect attempt instead of an outage — every deposit credit, broadcast and
|
||||
# confirmation goes through this one connection, which makes a single server the
|
||||
# platform's biggest single point of failure. A typo here fails at startup rather
|
||||
# than during the outage when the fallback is what you need.
|
||||
#
|
||||
# AT LEAST 2 are required (B-84): every decision that moves money is corroborated by
|
||||
# the *other* servers and needs two independent answers, so with fewer than two
|
||||
# fallbacks the server refuses to start (override: ALLOW_NO_QUORUM, development only).
|
||||
# Configure three or more in production, so one being down does not stop the lottery.
|
||||
#
|
||||
# These are the mainnet bootstrap servers from the PalladiumWallet repo
|
||||
# (src/Core/Chain/ChainProfiles.cs, ChainProfiles.Mainnet.BootstrapServers) — the
|
||||
# same ones the reference wallet falls back to:
|
||||
# Example: ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
|
||||
ELECTRUM_FALLBACK_SERVERS=
|
||||
ELECTRUM_FALLBACK_SERVERS=173.212.224.67:50002,144.91.120.225:50002,66.94.115.80:50002,89.117.149.130:50002
|
||||
|
||||
# Optional: SHA-256 (hex) of the primary server's DER certificate. A connection that
|
||||
# presents another certificate fails like an unreachable server. Empty = not pinned.
|
||||
ELECTRUM_CERT_PIN=
|
||||
|
||||
# Development only: start with fewer than 2 fallback servers (corroboration then falls
|
||||
# back to the weak rule: a single answer decides, or nothing is checked at all).
|
||||
# NEVER true in production.
|
||||
ALLOW_NO_QUORUM=false
|
||||
|
||||
# Fernet key protecting the master xprv at rest. Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
|
||||
@@ -8,4 +8,3 @@ master.xprv.enc
|
||||
*.egg-info/
|
||||
logs/
|
||||
data/
|
||||
flowchart/*.pdf
|
||||
@@ -0,0 +1,260 @@
|
||||
# BUGS.md — open findings
|
||||
|
||||
Two audits currently have open entries: 2026-08-03 (B-71, B-72, below) and the
|
||||
2026-09-28 specification review (B-73 … B-98, at the end of the file).
|
||||
|
||||
## Audit of 2026-08-03
|
||||
|
||||
Third full-codebase audit, opened after the 2026-07-26 (B-01 … B-24) and
|
||||
2026-07-27 (B-25 … B-49) lists were emptied. Numbering continues from the last
|
||||
fixed finding, B-51.
|
||||
|
||||
The list opened at B-52 … B-72 and holds only what is still **open**: a finding is
|
||||
removed from this file once it is fixed, and is not listed here afterwards. Per
|
||||
CLAUDE.md's convention each entry gets its own commit with its own regression test,
|
||||
and the `B-nn` marker goes in a comment next to the fix, so
|
||||
`git log --all --grep 'B-nn'` is the record of how any closed finding was closed.
|
||||
|
||||
State of the tree at audit time: 264 unit tests, all passing; `tests/integration/`
|
||||
still empty; withdrawal and the RBF bump path still never live-broadcast.
|
||||
|
||||
Verified as *not* broken while looking for these: i18n key parity (146 identical
|
||||
keys across all 7 languages), HTML escaping of every user-controlled value in
|
||||
`admin.js`, the Alembic chain (linear, single head, matching `models.py`),
|
||||
strictly-integer satoshi arithmetic everywhere, and `.gitignore` coverage of
|
||||
secrets/DB/logs (nothing sensitive is tracked in git).
|
||||
|
||||
Severity is about consequence, not likelihood:
|
||||
**critical** = money stuck or lost, or the lottery stops;
|
||||
**high** = a security control that does not hold;
|
||||
**medium** = wrong behaviour with a bounded blast radius;
|
||||
**low** = drift between documentation and code.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### (not new) `drawing` does not resume after a restart
|
||||
|
||||
Already tracked as an accepted gap in CLAUDE.md's "Known gaps", not re-numbered
|
||||
here. Worth restating in context: with `restart: unless-stopped` on the app
|
||||
container, this is the one state that gets stuck with money in play, and it
|
||||
remains the last prerequisite for running unattended.
|
||||
|
||||
---
|
||||
|
||||
## Low — documentation and consistency drift
|
||||
|
||||
### B-71 — `.env` points `MASTER_KEY_PATH` at a second copy of the master key
|
||||
|
||||
CLAUDE.md's deployment section prescribes pointing `MASTER_KEY_PATH` at the
|
||||
host-side `./data/keys/master.xprv.enc` so the venv scripts and the container read
|
||||
one file. `.env` instead sets `./master.xprv.enc`, and both files now exist in the
|
||||
working tree (both gitignored). They were verified during this audit to decrypt to
|
||||
the *same* xprv, so nothing has diverged yet — but `scripts/decrypt_master_key.py`
|
||||
reads a different file from the one the container uses, and a future
|
||||
`generate_master_key.py --overwrite` would split them silently, with an ops
|
||||
recovery path that then reports the wrong key.
|
||||
|
||||
The same duplication exists for the database (`./plm_lottery.db` next to
|
||||
`data/db/plm_lottery.db`), which is less dangerous but equally confusing.
|
||||
|
||||
Fix: set `MASTER_KEY_PATH=./data/keys/master.xprv.enc` in `.env`, delete the stray
|
||||
root copy once confirmed redundant, and state the same for `DATABASE_URL`.
|
||||
|
||||
### B-72 — `docs/setup.md` still frames setup as "locally or via Docker"
|
||||
|
||||
`docs/setup.md:1-10` lists Python as a prerequisite "for the local/venv workflow"
|
||||
and describes the master-key step as local *or* Docker, while B-44 made Docker the
|
||||
only supported way to run the server (`docs/running-the-server.md` and README were
|
||||
updated, this file was not). The venv genuinely is needed for tests, migrations and
|
||||
the key scripts — the wording just needs to say that instead of implying a second
|
||||
way to run the server.
|
||||
|
||||
---
|
||||
|
||||
# Audit of 2026-09-28 — specification review (B-73 … B-98)
|
||||
|
||||
Fourth audit, run against the new specification in `docs/` rather than the code
|
||||
alone: four independent reviews (coin/chain mechanics, money state machine,
|
||||
DevOps, spec cross-validation), with the critical claims re-verified in code.
|
||||
The target behaviour of every entry below is now written into `docs/` as
|
||||
requirements marked `*Stato:* da implementare (B-nn)`; the Italian overview, the
|
||||
design decisions and the implementation order are in
|
||||
[docs/piano-correzioni.md](docs/piano-correzioni.md). The code has **not** been
|
||||
changed yet. Same convention as above: one commit per entry, with its own
|
||||
regression test, spec + code + test together.
|
||||
|
||||
Design decisions taken with the owner on 2026-09-28 (they change existing
|
||||
decisions, see `docs/limiti-noti.md`):
|
||||
|
||||
- **Draw by commit-reveal.** The winner must not be computable by anyone who
|
||||
controls block production, since PLM's hashrate (~22 TH/s) is rentable.
|
||||
- **Never release funds on doubt.** A money-moving row is rolled back only on proof
|
||||
(an explicit rejection, or its inputs confirmed-spent elsewhere), never on a
|
||||
timeout or on one server's silence.
|
||||
- **One pool address per round**, so a round's money, history and payout are
|
||||
isolated from every other round's.
|
||||
- **Deposits need 6 confirmations** (configurable), everything else keeps 1: the
|
||||
deposit is the only input a user can double-spend.
|
||||
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
|
||||
on deep ones, and pay out only what is verifiably on chain.
|
||||
|
||||
## Critical
|
||||
|
||||
### B-79 — A payout normally has no change output, so it cannot be fee-bumped
|
||||
|
||||
The pool normally holds exactly the round's bet outputs, the payout spends them all
|
||||
for exactly `pool_amount_sats`, so there is no change and `bump_fee` can never help.
|
||||
A payout stuck in a mempool is also never abandoned (the server still knows it): the
|
||||
round stays in `paying_out` and no new round opens.
|
||||
|
||||
Fix: every payout carries a **bump reserve** output of
|
||||
`max(DUST_LIMIT_SATS, estimated_vsize × 10 sat/vB)`, taken from the commission share
|
||||
and paid to the pool reserve address (`m/84'/746'/0'/1/0`, the old pool address);
|
||||
RBF shrinks that output. `rounds_can_open` refuses a configuration in which a
|
||||
round of 1 or of `MAX_PARTICIPANTS_PER_ROUND` participants could not cover winner
|
||||
fee + reserve + dust commission, and `PUT /admin/config` refuses a bet amount or
|
||||
fee rate that would fail the same check (spec: RND-40).
|
||||
|
||||
### B-81 — `drawing` wedges on any exception, not only on restart
|
||||
|
||||
The participant snapshot and `tip_at_close` live only in memory; `_tick` skips
|
||||
`drawing`. Any exception during the wait (`database is locked` writing an audit row,
|
||||
a malformed header) leaves the round in `drawing` forever.
|
||||
|
||||
Fix: persist `draw_height`, `participants_hash` and each participant's
|
||||
`draw_position` when entering `drawing`; `_tick` re-enters the wait for a round in
|
||||
`drawing`, idempotently. Together with B-73's fixed height and committed seed, a
|
||||
resumed draw always yields the same winner.
|
||||
|
||||
### B-82 — A deposit reorged out after a bet leaves the round unpayable
|
||||
|
||||
Deposits are credited at 1 confirmation. A user can bet, then (if they lost)
|
||||
double-spend the deposit the bet spent with a private chain: the bet disappears but
|
||||
its participant stays `confirmed` and counted, and the payout fails "insufficient
|
||||
pool UTXOs" forever with every other player's money stuck. Immature coinbase outputs
|
||||
are also credited at 1 confirmation (maturity is 120).
|
||||
|
||||
Fix: deposits credited at `deposit_confirmations` (round_config, default 6),
|
||||
coinbase outputs at 120. Before building a payout the round address's UTXOs are
|
||||
verified with the quorum; participants whose bet outpoint is gone are marked
|
||||
`reorged_out` and the 70/30 split is computed on what is actually there. If the
|
||||
winner's own bet is gone the payout is held (`payout_blocked`) and the lottery pauses.
|
||||
|
||||
## High
|
||||
|
||||
### B-87 — The RBF bump can leave a dust change output
|
||||
|
||||
`bump_fee` only requires change > fee delta. A 294-sat change (full-balance
|
||||
withdrawal) becomes ~153 sat after the minimum bump and every replacement is refused
|
||||
by relay, every 30s.
|
||||
|
||||
Fix: when a bump would leave change below `DUST_LIMIT_SATS`, the change output is
|
||||
dropped and its value goes to the fee (the replacement is then final); `RbfError`
|
||||
and every refused bump write a `rbf_bump_failed` audit row.
|
||||
|
||||
### B-89 — Key problems surface only at first use
|
||||
|
||||
Startup checks only that the key file exists and `XPRV_ENCRYPTION_KEY` is non-empty;
|
||||
a wrong key or a different xprv is found at the first registration or payout, and a
|
||||
different xprv silently moves the pool address. The scripts accept any xprv and write
|
||||
files `0644`.
|
||||
|
||||
Fix: at startup decrypt the key and check the pool reserve address and a sample of
|
||||
stored user addresses; refuse to serve on mismatch. The scripts refuse an xprv whose
|
||||
derived addresses differ from the DB's and write `0600`.
|
||||
|
||||
### B-90 — Restoring a backup is unsafe
|
||||
|
||||
A restored DB reopens rounds that were already drawn and paid, and reassigns the
|
||||
derivation indexes of users registered after the backup to new users — who then
|
||||
receive the old users' funds.
|
||||
|
||||
Fix: `START_PAUSED=true` starts with the lottery paused; at startup the next 20
|
||||
indexes past the highest assigned are checked for on-chain history and, if any has
|
||||
some, registration is refused until the operator reconciles (`restore_gap_detected`);
|
||||
per-round addresses (B-80) and B-82's pre-payout check make a replayed round
|
||||
unpayable instead of payable twice.
|
||||
|
||||
### B-91 — Host and supply-chain hardening
|
||||
|
||||
The container runs as root, `.env` and key files are world-readable, dependencies are
|
||||
unpinned `>=` and base images float, secrets sit in env vars visible to
|
||||
`docker inspect`.
|
||||
|
||||
Fix: non-root `USER`, `0600`/`0700` permissions, a hash-pinned lock file,
|
||||
digest-pinned base images, the Fernet key mounted as a file rather than an env var.
|
||||
|
||||
## Medium
|
||||
|
||||
### B-92 — No health, readiness or real audit browsing
|
||||
|
||||
`/health` always answers ok; nothing exposes Electrum state; the audit endpoint
|
||||
returns only the latest 500 rows unfiltered; registration writes no audit row; the
|
||||
access log records Caddy's IP; app logs go only to the file.
|
||||
|
||||
Fix: `GET /health/ready` (Electrum connected, tip age < 360s, scheduler heartbeat <
|
||||
30s) used as the container healthcheck; Electrum status in `/admin`; audit filters by
|
||||
event type, round, user and time, with pagination; `user_registered` audit row with
|
||||
index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
|
||||
|
||||
### B-93 — Deploys and rollbacks
|
||||
|
||||
A deploy with an open SSE stream never shuts down gracefully (uvicorn waits, Docker
|
||||
kills after 10s); an older image cannot start on a newer DB; images are untagged;
|
||||
`SITE_ADDRESS` passed per command reverts to `localhost` on the next plain
|
||||
`docker compose up`.
|
||||
|
||||
Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task
|
||||
cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`;
|
||||
documented pre-deploy backup and downgrade procedure.
|
||||
|
||||
### B-94 — Account and endpoint abuse limits
|
||||
|
||||
`change-password` has no rate limit, no audit and no maximum length; login fields
|
||||
have no length bounds; `POST /bug-reports` is anonymous and unlimited;
|
||||
`GET /admin/users` has no pagination; admin, SSE and framework errors are bare
|
||||
strings; static JS/CSS have no `Cache-Control`.
|
||||
|
||||
Fix: throttle and audit (`password_changed`) the password change, cap every
|
||||
credential field at 256, rate-limit bug reports per IP, paginate the user list,
|
||||
structured errors everywhere, `Cache-Control: no-cache` on static files.
|
||||
|
||||
### B-95 — Round parameters read live instead of snapshotted
|
||||
|
||||
Only duration and cooldown are snapshotted at open; bet amount, `fee_address` and the
|
||||
new `draw_confirmations` are read live, so an edit mid-round changes the round in
|
||||
progress (e.g. redirects its commission).
|
||||
|
||||
Fix: snapshot `bet_amount_sats`, `fee_address` and `draw_confirmations` on the round
|
||||
at open. Optional per-round cap `max_round_pool_sats` (round_config, 0 = disabled):
|
||||
once reached, further bets get `round_full`.
|
||||
|
||||
### B-96 — Smaller state inconsistencies
|
||||
|
||||
A reduced full-balance withdrawal is not re-checked against the minimum; an abandoned
|
||||
payout leaves `winner_amount_sats`/`fee_amount_sats` set; a promoted payout writes no
|
||||
`payout_sent` and a promoted bet no `bet_placed`; the reconciler pings SSE for every
|
||||
row it checks; concurrent refreshes of one user can collide on the unique outpoint
|
||||
and end the Electrum session; `listener.client` is read outside the per-user lock;
|
||||
a bet covered only by pending change gets `insufficient_balance` instead of
|
||||
`balance_pending_confirmation`. A round waiting in `closing` on a bet that never
|
||||
leaves the mempool gives no signal at all.
|
||||
|
||||
Fix: each as described, one regression test per item; for the `closing` wait, a
|
||||
`closing_stalled` audit row every 1800s past the deadline and `closing_waiting_since`
|
||||
on `GET /rounds/current` (spec: TX-29).
|
||||
|
||||
### B-98 — Coin selection stops at the first covering UTXO, refusing a payable bet
|
||||
|
||||
Found while writing the spec on 2026-09-28 (medium). `select_utxos` returns as soon
|
||||
as the selected total covers the target. When that happens exactly (a 10 PLM coin
|
||||
for a 10 PLM bet), the change is 0: the bet is refused with
|
||||
`balance_leaves_no_change` and a withdrawal is reduced by `DUST_LIMIT_SATS`, even
|
||||
though a second coin would have left a proper change output.
|
||||
|
||||
Fix: select until the total covers `target + DUST_LIMIT_SATS` (still largest-first,
|
||||
still capped at `max_inputs`); only when every allowed coin together cannot reach it
|
||||
fall back to today's behaviour (refuse the bet, reduce the withdrawal). Spec:
|
||||
`docs/scommesse-e-prelievi.md` BET-24.
|
||||
@@ -8,15 +8,31 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 253 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 617 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. They were tracked in a `BUGS.md` that was deleted once the list emptied, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
|
||||
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71 … B-98 at the time of writing). The spec in `docs/` already describes the *target* behaviour for all of them — every affected requirement is marked `*Stato:* da implementare (B-nn)` with an `*Oggi:*` line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in [docs/piano-correzioni.md](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
|
||||
|
||||
Before writing code, read the "Architecture" section below in full plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) (the 5-phase flow) and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) (the round/draw lifecycle). Every node **and edge label** (conditions, retries, loops) is a behaviour that must be implemented as described. Regenerate the companion PDFs with `flowchart/render-pdf.sh <file>.mmd` after editing either.
|
||||
**[docs/](docs/README.md) is the specification, and it is authoritative.** Every verifiable rule there has a stable requirement ID (`RND-04`, `BET-02`, …) with `*Codice:*`/`*Test:*` pointers to what implements it; the index is [docs/come-funziona.md](docs/come-funziona.md) and the ID convention and change workflow are in [docs/processo-di-modifica.md](docs/processo-di-modifica.md). A behaviour change starts in the spec: edit or add the requirement, implement it, cite the ID in the test docstring and the commit message, and ship spec + code + test in one commit. If this file and the spec disagree, the spec wins and this file is the one to fix. Before writing code, read the relevant spec documents and the "Architecture" section below.
|
||||
|
||||
Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44).
|
||||
Human-facing guides are in [docs/](docs/), in Italian by explicit request (an exception to the English-only rule): the specification starting at [come-funziona.md](docs/come-funziona.md), the operational guides [setup.md](docs/setup.md), [running-the-server.md](docs/running-the-server.md), [guida-utente.md](docs/guida-utente.md), [guida-admin.md](docs/guida-admin.md). README's Quick start and `docs/running-the-server.md` are Docker-only, matching this file — a bare `uvicorn --reload` workflow was removed from both (B-44).
|
||||
|
||||
## Documentation map (docs-first, SSOT, ADR)
|
||||
|
||||
**Entry point: [docs/README.md](docs/README.md)** — the index of everything under `docs/`. Three rules, all in [docs/processo-di-modifica.md](docs/processo-di-modifica.md):
|
||||
|
||||
1. **Docs-first.** Behaviour changes start in `docs/`, never in the code. Spec + code + test ship in one commit, with the requirement/ADR ID in the test docstring and the commit message.
|
||||
2. **SSOT.** Every fact has one owner; everything else links to its ID instead of restating it. *Why* → an ADR ([docs/adr/](docs/adr/README.md)); *what* (verifiable rule) → a `PREFIX-NN` requirement in the domain document; *how* → the code; accepted limits → [limiti-noti.md](docs/limiti-noti.md) (`LIM-nn`); found/fixed defects → [storico-bug.md](docs/storico-bug.md) (`B-nn`, open ones in [BUGS.md](BUGS.md)). If two documents disagree, the spec wins and the other one gets fixed. This file included: it summarises, it does not own.
|
||||
3. **One ADR per feature/decision.** A new feature, or a change of an existing choice, starts with an ADR from [docs/adr/TEMPLATE.md](docs/adr/TEMPLATE.md) (next free `ADR-NN`, never reused or rewritten — a superseded one becomes "Sostituita da ADR-MM"), then its requirements, then the code. Add it to the area index and the feature → ADR table in [docs/adr/README.md](docs/adr/README.md). Before touching a feature, read its ADRs there.
|
||||
|
||||
| Need | Go to |
|
||||
|---|---|
|
||||
| Why is it built this way / alternatives rejected | [docs/adr/README.md](docs/adr/README.md) (the old `DEC-nn` are now `ADR-nn`, same numbers) |
|
||||
| What exactly must it do | domain docs listed in [docs/README.md](docs/README.md) (`RND`, `BET`, `WDR`, `DEP`, `REG`, `TX`, `ELC`, `AUD`, `ARC`, `DAT`, `API`, `SEC`, `OPS`) |
|
||||
| Narrative overview | [docs/come-funziona.md](docs/come-funziona.md) |
|
||||
| Accepted limits | [docs/limiti-noti.md](docs/limiti-noti.md) |
|
||||
| Fix order for open B-nn | [docs/piano-correzioni.md](docs/piano-correzioni.md) |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -33,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||
|
||||
python -m pytest # all 253 tests
|
||||
python -m pytest # all 617 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
@@ -55,13 +71,13 @@ docker compose down
|
||||
|
||||
`SITE_ADDRESS` unset → `localhost`, Caddy issues a self-signed cert from its internal CA (browser warning on first visit is expected; `curl -k`). `SITE_ADDRESS=lottery.example.com docker compose up -d` → real Let's Encrypt cert, automatically renewed (needs DNS pointing here and ports 80+443 reachable).
|
||||
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
|
||||
The `Caddyfile` sends baseline security headers — HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and a CSP scoped to `default-src 'self'` plus the Google Fonts `@import` in `style.css`/`admin.css`. It also overwrites `X-Forwarded-For` with the real peer (`header_up X-Forwarded-For {remote_host}`, B-54) — Caddy otherwise *appends* to whatever the client sent, which made every IP-keyed control (the B-33 throttles, B-38's SSE cap) bypassable; `app/api/client_ip.py` independently reads the *last* hop, so either half closes it. `script-src`/`style-src` need `'unsafe-inline'` because both SPAs use inline `onclick` handlers and `style=""` attributes throughout — removing those is a separate, larger refactor, not a header change. `restart: unless-stopped` on `app` means a mid-round crash auto-restarts: `closing` and `paying_out` resume on their own, `drawing` does not (see Known gaps).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Python 3.12+, FastAPI, SQLAlchemy 2 async + Alembic, SQLite via aiosqlite, `embit` for keys/PSBT/tx parsing.
|
||||
- **PLM access via the Electrum protocol only** (no full node/P2P). Dev bootstrap server: `santantonio.sytes.net:50002` (SSL).
|
||||
- Auth: Argon2 hashing + JWT (HS256, 24h, **no revocation** — B-34).
|
||||
- Auth: Argon2 hashing + JWT (HS256, 24h). Tokens **are** revocable (B-34): the token carries a `tv` claim, `User.token_version` is bumped by a self-service password change and by an admin reset, and `get_current_user`/`get_optional_user` reject any token whose `tv` no longer matches — so changing the password invalidates every session issued before it, instead of leaving them valid for up to `jwt_expire_minutes`. A token predating the claim decodes as `tv = 0`, which is what a migrated user starts at, so the deploy didn't log everyone out. Argon2 costs tens of ms of CPU per call by design, so every async caller goes through `hash_password_async`/`verify_password_async` (`run_in_threadpool`, B-55) — inline it froze the whole process, background tasks included, for the duration of every login. The sync pair stays for tests and scripts.
|
||||
- Secrets: master xprv Fernet-encrypted at rest, encryption key in an env var (never in the DB or git). `validate_runtime_secrets()` (`app/config.py`, called from the lifespan — deliberately *not* a `Settings` validator, so imports and tests need no real secrets) makes the server **refuse to serve** if `JWT_SECRET` < 32 chars or `XPRV_ENCRYPTION_KEY` is empty. An empty `ADMIN_TOKEN` is deliberately non-fatal: `require_admin` then denies everything, i.e. a locked panel, not an open one.
|
||||
- **Operational config lives in the DB, not in env vars**: every business/round parameter is one row of `round_config` (`app/rounds/config.py`), editable live from `/admin` — no redeploy, no restart. Defaults for a fresh instance are column defaults on `RoundConfig` (`app/db/models.py`), *not* `app/config.py`. Only secrets and infra wiring (master key, JWT secret, Electrum hosts, admin token, DB URL) stay in `.env`, since those need a restart anyway.
|
||||
|
||||
@@ -84,35 +100,40 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
|---|---|---|
|
||||
| Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable |
|
||||
| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit |
|
||||
| Round duration / cooldown | 600s / 30s | `RoundConfig` |
|
||||
| Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress |
|
||||
| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` |
|
||||
| Draw confirmations | 3 blocks on the draw block (`draw_confirmations`, 1–20) | `RoundConfig`, admin-editable, **snapshotted onto `Round.draw_confirmations` at open** (B-73) |
|
||||
| Fee rate / RBF timeout | 1 sat/vB / 900s | `RoundConfig` |
|
||||
| Min withdrawal | = current `bet_amount_sats` (no separate field) | `withdrawals/service.py` |
|
||||
| Min deposit | none | — |
|
||||
| Min password length | 8 | `auth/security.py:MIN_PASSWORD_LENGTH` |
|
||||
| Confirmations, every tx kind | **1** | hardcoded in `tx/confirmation.py` |
|
||||
| Max inputs per tx | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max inputs per *user* tx (bet, withdrawal) | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
|
||||
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` |
|
||||
|
||||
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11).
|
||||
`GET /rounds/current`'s `jackpot_sats` is the winner's 70% share, not the whole pool, and the pool is summed from the participants' actual `bet_amount_sats` (each already net of its own bet fee) rather than `count × current bet amount` — editing the bet amount mid-round must not move an in-progress round's advertised jackpot (B-11). It counts **confirmed participants only** (B-65), matching what the draw picks from and what the payout can spend, with `pending_participant_count`/`pending_jackpot_sats`/`has_pending_bets` reporting the in-flight bets alongside — inclusive figures, not deltas, exactly like `pending_balance_sats` (see "Balance display"). `/`'s round card shows the confirmed numbers big and the difference as an amber "+N in attesa" suffix, so a player who just bet sees their own bet immediately without the advertised jackpot ever exceeding what will be paid.
|
||||
|
||||
**Round cooldown** (`round_cooldown_seconds`, not in the original flowchart): gap after a round closes before the next opens, so players can see the outcome.
|
||||
**Round cooldown** (`round_cooldown_seconds`, not in the original design): gap after a round closes before the next opens, so players can see the outcome.
|
||||
|
||||
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner.
|
||||
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx` so far, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
|
||||
|
||||
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time.
|
||||
|
||||
## Code map
|
||||
|
||||
| Package | Contents |
|
||||
|---|---|
|
||||
| `app/main.py` | entry point: lifespan starts the six background tasks, mounts the routers and `app/static/` |
|
||||
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`; `app/api/errors.py` holds the error contract |
|
||||
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user` |
|
||||
| `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`, `bug_reports`; `app/api/errors.py` holds the error contract and `app/api/client_ip.py` the trusted-peer extraction every IP-keyed control uses |
|
||||
| `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user`, login/registration throttling (`rate_limit.py`) |
|
||||
| `app/db/` | `models.py` (all tables + the active-round index), engine/session factories |
|
||||
| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
|
||||
| `app/wallet/` | HD derivation (users `0/i`, per-round pool `2/<round_id>`, reserve `1/0`) + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
|
||||
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
|
||||
| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) |
|
||||
| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers |
|
||||
| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math + winner pick), `config.py`, `events.py` (SSE pub/sub) |
|
||||
| `app/tx/` | `broadcast.py` (RBF bumper), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
|
||||
| `app/rounds/` | `scheduler.py` (close/draw/payout), `service.py` (open/active-round rules), `draw.py` (header math, powLimit, commit-reveal seed + winner pick), `config.py`, `events.py` (SSE pub/sub) |
|
||||
| `app/tx/` | `broadcast.py` (RBF bumper), `outcome.py` (broadcast verdict, B-76), `versions.py` (every txid of a row, B-77), `confirmation.py` (poller + handler registry), `reconcile.py`, `locks.py` (per-user locks) |
|
||||
| `app/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` |
|
||||
|
||||
## Background tasks
|
||||
@@ -128,48 +149,48 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
|
||||
| `PendingTransactionReconciler` | `tx/reconcile.py` | at startup, then 120s | resolves `building`/`pending` rows against the chain |
|
||||
| `DepositReconciler` | `deposits/reconcile.py` | 300s (sleeps first) | re-`refresh_user`s every address, catching a silently-lost subscription (B-30) |
|
||||
|
||||
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
|
||||
Chain access goes through `listener.client`, passed as `lambda: listener.client` so a reconnect swaps the client under its consumers; a task finding it `None` skips that cycle instead of failing. **A non-null `client` means the tip is already known**: `_run_once` publishes it only after the first header has been applied (B-63), so `client is not None` can be read as "the chain is reachable *and* we know where it is" — `tip_height` is never the initial 0 behind a live client, which is what the draw depends on (see DRAW below). `DepositReconciler` takes the whole listener instead, reusing `refresh_user` so the periodic and notification-driven paths can't diverge.
|
||||
|
||||
## Electrum connection
|
||||
|
||||
One connection serves everything — deposit credits, broadcasts, confirmations, the tip the draw waits on — so it's both the biggest single point of failure and, with a hostile server on the other end, the biggest integrity risk. Five defences:
|
||||
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Rotation.** `ELECTRUM_HOST`/`PORT` is primary, `ELECTRUM_FALLBACK_SERVERS` a comma-separated `host:port[:notls][:pin=<sha256>]` list (`client.py:parse_endpoints` rejects malformed entries at startup, not during the outage when the fallback is needed; `pin=` fixes the server's DER certificate hash, `ELECTRUM_CERT_PIN` does the same for the primary, B-84). The server **refuses to start with fewer than 2 fallbacks** (`config.validate_quorum_servers`, B-84; `ALLOW_NO_QUORUM=true` overrides it, development only). After any failed or dropped session the next server is tried immediately; the backoff (1s doubling to 30s) only kicks in once every server has had a turn.
|
||||
- **Bounded requests** (`_REQUEST_TIMEOUT_SECONDS` = 15s); a timeout tears the connection down. Unbounded waits used to hang `POST /bets` *while holding the per-user lock*, and could stall the confirmation poller permanently.
|
||||
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **Headers are validated, not trusted** (`_apply_header`): the tip never regresses, a header must meet the difficulty target it claims, and a single-block advance must chain from the current tip's hash. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner.
|
||||
- **A quorum corroborates the two money-moving decisions** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29). No fallbacks configured → returns True (the accepted risk of an empty `ELECTRUM_FALLBACK_SERVERS`); nobody answers → returns **False**, since an unreachable network proves nothing.
|
||||
- **The drop is observable**: `client.wait_closed()` resolves when the read loop dies, and `_run_once` races it against the notification consumers and a 60s `server.ping`. A server that keeps answering pings but stopped following the chain is caught too (B-86): at every ping, if no header has been *accepted* for 360s (`_STALL_SECONDS`), `_check_not_stalled` asks the other servers for their tip and ends the session with `ServerStalledError` if any is higher, so `run()` rotates; none higher means a quiet chain and the session goes on. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
|
||||
- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. A followed reorg writes `chain_reorg_detected` *before* the window moves (`_record_reorg`, B-85), and a depth of 2 or more pauses the lottery with `paused_reason = 'reorg'` in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused *without* ending the session (B-64): one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
|
||||
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
|
||||
|
||||
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
|
||||
|
||||
## Architecture — the 5 phases
|
||||
|
||||
Diagrams: [platform-overview.mmd](flowchart/platform-overview.mmd), [round-lifecycle.mmd](flowchart/round-lifecycle.mmd).
|
||||
Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vita-del-round.md](docs/ciclo-di-vita-del-round.md) (the round/draw lifecycle).
|
||||
|
||||
**REG** — on signup the server derives a P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from the encrypted master xprv. Permanent, and doubles as deposit address, winnings address and withdrawal change address.
|
||||
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation**, with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
**DEP** — the listener subscribes to the user's scripthash; balance is credited after **1 confirmation** and only once the other servers corroborate the outpoint and its amount (B-59), with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
|
||||
|
||||
**PLAY** — fixed cost, **at most one active bet per user**. PSBT user-address → pool-address, always with a **change output back to the same user address** (a user's balance must never exactly equal the bet). Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
**PLAY** — fixed cost, **at most one active bet per user**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** of at least `DUST_LIMIT_SATS` — a user's balance must never exactly equal the bet, and since B-62 that's enforced (`balance_leaves_no_change`) rather than assumed. Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
|
||||
|
||||
**DRAW** — configurable timer (default 600s):
|
||||
- *Bet cutoff is the round's own deadline* (`opened_at + round_duration_seconds`), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`.
|
||||
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
|
||||
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
|
||||
- *Algorithm* (deliberately simple, meant to be replaced): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount.
|
||||
- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
|
||||
- *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`.
|
||||
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
|
||||
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
|
||||
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
|
||||
|
||||
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern.
|
||||
**WITHDRAW** — the only way out to an external address: PSBT user-address → external + change back to the user, fee deducted from the withdrawn amount, same RBF pattern. A full-balance withdrawal moves `balance - DUST_LIMIT_SATS` so the change output (and with it the ability to fee-bump) always exists — `Withdrawal.amount_requested_sats` vs `amount_sent_sats` is what records the difference (B-62).
|
||||
|
||||
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set.
|
||||
PLAY and WITHDRAW share a **per-user lock** (`tx/locks.py`): a bet-build and a withdrawal-build can never be in flight at once, since both spend the same UTXO set. That is the *only* mutual exclusion between them (B-70): a withdrawal is accepted while a bet is still unconfirmed, as long as confirmed, unspent UTXOs cover it — the lock plus `place_bet`/`request_withdrawal` only ever offering `select_utxos` UTXOs with `spent_txid IS NULL` is what prevents the two from picking the same input, so freezing the rest of the balance for a block on top of that would restrict the user without protecting anything.
|
||||
|
||||
**Three separate on-chain confirmations sit between the timer hitting zero and the payout landing** — a common point of confusion:
|
||||
1. **Last bet's confirmation** — the round doesn't even flip to `"closing"` until every broadcast bet has 1 conf (`_tick`'s `pending_count` check). May already have happened before the deadline.
|
||||
2. **The draw block** — `_wait_for_next_block` waits for `tip_height > tip_at_close`, recorded only once step 1 is done, so this is necessarily a later block.
|
||||
2. **The draw block** — fixed at `draw_height = tip_at_close + 1` (recorded only once step 1 is done, so necessarily a later block) and read once the chain has `draw_confirmations` blocks from it (3 by default, so it is really ~3 blocks, not one).
|
||||
3. **Payout confirmation** — built only after step 2's winner is known, so it needs yet another block; the generic `ConfirmationPoller` tracks it.
|
||||
|
||||
At 120s blocks that's ~4–6 min worst case (last bet confirms right at the deadline), ~2–4 min best case — independent of `draw_animation_seconds`.
|
||||
At 120s blocks that's ~8–10 min worst case (last bet confirms right at the deadline), ~6–8 min best case with the default 3 draw confirmations — independent of `draw_animation_seconds`.
|
||||
|
||||
## Balance display
|
||||
|
||||
@@ -192,10 +213,12 @@ Both SPAs refresh on an `update` message *or* on `open` — the latter fires on
|
||||
Everything that spends money is written **before** it is broadcast and resolved against the chain afterwards; this is what makes the system recover without manual DB edits. `PendingTransaction.status`: `building` → `pending` → `confirmed`, or `failed`.
|
||||
|
||||
- `building` is written first, UTXOs already marked `spent_txid`, and committed *before* the broadcast (`bets/service.py`, `withdrawals/service.py`, and `scheduler.py:_trigger_payout` — the same shape in four phases, so no DB session is ever held across a network call). A crash in that window leaves evidence, not coins spent on-chain with no record.
|
||||
- A refused broadcast releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**, since the network refused it, not the caller.
|
||||
- `tx/reconcile.py` asks the chain about anything still `building`/`pending`: present → promote; positively unknown → `failed` with a `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged. Grace differs by state (120s `building`, 6h `pending`, so the bumper gets its attempts first). A *transport* failure never abandons anything — only a server that positively doesn't know the tx, currently inferred by substring-matching the error text (fragile — B-41).
|
||||
- **Nothing is released on doubt (B-76).** `tx/outcome.py:broadcast_with_verdict` sorts a broadcast into accepted / refused / uncertain. Only an explicit JSON-RPC error (`ElectrumRpcError`) that `ElectrumListener.transaction_known` then confirms — the active server and the quorum minimum of the others all say they do not know the tx — releases the UTXOs, restores the balance, removes the participant (or marks the withdrawal `failed`), audit-logs, and raises `broadcast_failed` → **502**. If any server returns the tx, or the message is an "already known" one, it counts as accepted. A timeout, a dropped connection or a refusal that could not be verified leaves the rows `building` for the reconciler and the API answers **202**. An accepted tx is also fanned out to every other server (`fan_out_transaction`, best effort).
|
||||
- `tx/reconcile.py` asks the **quorum** about anything still `building`/`pending` (every txid of the row, on the active server and every other one): present on any server → promote (and adopt the version found); otherwise **nothing is given up without proof** (B-78, ADR-51): a row is abandoned — `failed` with the proof in `failure_reason`, inputs released, domain row rolled back, `pending_tx_abandoned` logged — only if one of its inputs is spent *in a block* by another tx and the other servers agree (`confirmed_spender`), or no server knows any txid *and* a rebroadcast of its latest raw tx (`rebroadcast_everywhere`) is refused by every server for missing/spent inputs. A fee refusal is not proof (`building` → `pending` for the bumper); too few answers, a transport failure or the age of the row prove nothing. Grace only decides from when a row is examined (120s `building`, 6h `pending`). The write re-checks status *and* `current_txid` under `begin_immediate` (TX-26). `failed` rows stay watched for 14 days (`failed_at`): one that confirms late is marked `confirmed`, `pending_tx_resurrected` is logged and the lottery pauses (`paused_reason = 'resurrected_tx'`, `rounds/pause.py`); the linked rows are never corrected by the system. A tx still in some server's mempool is never abandoned, so in practice that is what bounds the `closing` wait.
|
||||
|
||||
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
|
||||
**SQLite transactions are real ones (B-88).** `_register_sqlite_pragmas` sets `synchronous=FULL` and switches pysqlite's implicit `BEGIN` off (`isolation_level = None`); an engine `begin` event emits `BEGIN` itself, so every read is inside a transaction. Every transaction that moves money or fixes its state opens with `await begin_immediate(session)` (`app/db/base.py`) — `BEGIN IMMEDIATE`, i.e. the write lock *before* the first read (it commits the caller's read-only transaction first and refuses a session with unflushed changes). A lock not granted within `busy_timeout` raises `OperationalError`: **before** a broadcast that means "refuse" (`service_busy`, 503, nothing built or sent); **after** one nothing is undone — the row stays `building` and the reconciler resolves it. New money-moving write code must call `begin_immediate` first, and tests of that must use a file DB whose engine went through `_register_sqlite_pragmas` (an in-memory engine without it never emits `BEGIN IMMEDIATE`).
|
||||
|
||||
`UtxoEvent.spent_txid` must always equal the tx's *current* txid, so `bump_fee` retargets it along with `RoundParticipant.bet_txid`, `Withdrawal.txid` and `Round.payout_txid` on every bump. **Every version a row ever had is tracked (B-77):** `pending_transaction_txids` (`app/tx/versions.py`) holds each txid with its raw tx, written with the row and, for an RBF replacement, *before* it is broadcast; the poller and the reconciler look for all of them, and when the network holds a version other than the current one `broadcast.py:adopt_version` makes it current and retargets everything. `bump_fee` ends with a conditional `UPDATE … WHERE status='pending' AND current_txid=<replaced>`; zero rows is a logged conflict, never an overwrite. `broadcast_at` is the *first* broadcast and is never rewritten (the reconciler's abandon clock measures from it); `last_broadcast_at` is what a bump updates and `should_bump` reads. Confirmation handlers key off immutable ids (`round_id`/`user_id`, `withdrawal_id`), never the txid, which changes under them.
|
||||
|
||||
**Payouts retry, and are guarded against paying twice.** Every tick re-examines a `paying_out` round: `_retry_payout_if_due` throttles to one attempt per 60s using the latest `payout_failed` audit entry as its clock (a build failure leaves no DB row to throttle on), and every early return in `_trigger_payout` writes one, so `/admin` shows *why* a round is stuck. Before building, `_trigger_payout` refuses if a `building`/`pending` payout already exists for the round, and `_reserved_payout_outpoints` excludes pool UTXOs claimed by any unresolved payout — without both, a retry would pay the winner twice.
|
||||
|
||||
@@ -203,10 +226,11 @@ Everything that spends money is written **before** it is broadcast and resolved
|
||||
|
||||
## Frontends
|
||||
|
||||
Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler, `Cache-Control: no-store`.
|
||||
Two static SPAs served directly by FastAPI (`main.py` mounts `app/static/` and adds routes for `/admin`, `/guida`, `/report-bug`) — no build step, no framework, no bundler. `Cache-Control: no-store` is set on the four HTML routes only; the JS/CSS under the `StaticFiles` mount get ETag/Last-Modified and no Cache-Control.
|
||||
|
||||
- **`/`** — end-user test UI: register/login, then a navbar dashboard with four panels (Deposito with a QR from `GET /qr/{address}`, Bet, Prelievo, Profilo — account info + self-service password change via `POST /users/me/change-password`), above a persistent round-status card and the chain-status bar with the language switcher.
|
||||
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then five sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log; plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
||||
- **`/admin`** — gated by a token screen (not a login: just `X-Admin-Token` vs `ADMIN_TOKEN`), then six sections each backed by its own `/admin/*` endpoint: Parametri (`RoundConfig` + the Manutenzione card), Utenti (list, WIF privkey export, password reset — both audit-logged), Round, Transazioni pendenti, Audit log, Bug report (triage `open` → `read` → `resolved`, audit-logged `bug_report_status_changed`); plus a live Electrum/tip-height pill. **Deliberately not linked from `/`** in either direction.
|
||||
- **`/report-bug`** — standalone page (no navbar, own language switcher), reachable logged-in or logged-out: `POST /bug-reports` stores the report with the submitter attached when there is one, `GET /bug-reports/mine` is the reporter-side status view for the logged-in case, and `/admin`'s Bug report section is the triage end.
|
||||
|
||||
Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`.
|
||||
|
||||
@@ -219,7 +243,7 @@ Both talk to the same JSON API; there's no admin/user API split beyond `require_
|
||||
- `/report-bug`'s `bugReport.englishNotice` string is itself translated into all 7 languages — it just always *says*, in whichever language the visitor reads, to write the actual bug description in English (so the admin panel, which is Italian-operator-facing and untranslated, doesn't end up with reports in 7 different languages).
|
||||
- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`.
|
||||
|
||||
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients never special-case unexpected errors, and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
|
||||
**API error contract** (`app/api/errors.py`) — the API is single-language by design. Failures answer with a structured `detail`: `{"code", "message", "params"}`, where `message` is English for non-dashboard consumers and `code` is what the frontend maps to `error.<code>` in `i18n.js` (falling back to `message` for an unknown code). `BetError`/`WithdrawalError` subclass `ApiError` and carry the code from where the failure happens. Even the catch-all 500 handler answers in that shape (`internal_error`), so clients rarely special-case unexpected errors (the exceptions — `require_admin`'s 403/404s, the SSE 503, FastAPI's own 401/404/405/422 — still carry a bare-string or list `detail`; see `docs/api.md`), and the exception text stays in `logs/app.log`. Adding a user-facing error: give it a code, add `error.<code>` to all 7 languages, and pass values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling) instead of baking them into English text.
|
||||
|
||||
## Non-obvious domain decisions
|
||||
|
||||
@@ -230,6 +254,7 @@ Explicit design choices, not derivable from any single file — respect them:
|
||||
- **1 confirmation** for every tx kind. Don't introduce differing thresholds (3, 6, …) without an explicit decision.
|
||||
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
|
||||
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
|
||||
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
|
||||
- Argon2 hashing means **no password recovery, only reset**: `POST /admin/users/{id}/reset-password` sets a new random password, returns it once for the operator to relay, and logs `admin_password_reset`. No self-service reset exists (no email is ever collected); a logged-in user can only *change* their password by supplying the current one.
|
||||
- RBF bumps are paid by whoever's change the tx pays back to — the user for bets/withdrawals, the pool for payouts. Counterparty outputs (recipient, winner, fee address) are never touched; only the sender's own change shrinks (`bump_fee`).
|
||||
|
||||
@@ -237,12 +262,12 @@ Explicit design choices, not derivable from any single file — respect them:
|
||||
|
||||
Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here.
|
||||
|
||||
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_wait_for_next_block` after a crash. That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
|
||||
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback — an exact-amount tx or too-small change raises `RbfError`. Not permanent, though: an unbumpable tx that never confirms is eventually abandoned and its UTXOs released.
|
||||
- **`drawing` doesn't resume after a restart.** `_tick()` handles `open`, `closing` and `paying_out` (the last via `_retry_payout_if_due`); nothing re-enters `_complete_draw` after a crash (everything it needs is in the database since B-73, only the `_tick` branch is missing — B-81). That wait is unbounded by design (the draw's entropy genuinely depends on a future block) but no longer silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). Restart-resumption itself remains the last prerequisite for running unattended.
|
||||
- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released.
|
||||
- **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network).
|
||||
- **No user-facing history.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this.
|
||||
- **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius.
|
||||
- **No rate limiting anywhere** (register, bet, withdrawal, admin, SSE). For login this is a blocker, not a gap — tracked as B-33.
|
||||
- **`/guida` and `/report-bug` are placeholders** (`app/static/guida.html`, `report-bug.html`) — links work, content is "coming soon".
|
||||
- **No rate limiting on bet, withdrawal, admin or SSE.** Login has a per-username + per-IP failure throttle and registration a per-IP quota of 5 accounts/hour (`app/auth/rate_limit.py`: `RateLimiter` for failed guesses at a secret, `RollingQuota` for "how many of these may one source create" — B-33, B-58); everything else is unlimited.
|
||||
- **`/guida` is a placeholder** (`app/static/guida.html`) — the link works, the content is "coming soon". `/report-bug` is *not*: it is fully implemented and translated, with admin triage (see Frontends).
|
||||
- **No integration tests against a live Electrum connection.** `tests/integration/` is empty; live verification has all been manual (`scripts/electrum_smoke_test.py`, ad hoc scripts, real mainnet txs).
|
||||
- **Single-process assumptions**: the SSE broadcaster and the per-user locks are in-process only. A multi-worker deployment needs a shared channel and a DB/Redis lock. The round-uniqueness invariant is *not* in this category — it's a DB index.
|
||||
@@ -29,5 +29,15 @@
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
|
||||
}
|
||||
|
||||
reverse_proxy app:8123
|
||||
# B-54: Caddy *appends* the real peer address to whatever X-Forwarded-For the
|
||||
# client sent, so without this the header arrives as "<whatever the client
|
||||
# claimed>, <real ip>" and every IP-keyed control in the app (the login and
|
||||
# registration throttles of B-33, the SSE per-IP subscriber cap of B-38) is
|
||||
# defeated by simply rotating a fake value per request. Overwriting the header
|
||||
# with the actual peer makes the app's assumption true at the source; it also
|
||||
# reads the last hop rather than the first (app/api/client_ip.py), so the two
|
||||
# defences hold independently.
|
||||
reverse_proxy app:8123 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,10 @@ domain).
|
||||
## Documentation
|
||||
|
||||
- [CLAUDE.md](CLAUDE.md) — architecture, commands, domain decisions, known gaps (for anyone/anything working on the code)
|
||||
- [flowchart.mmd](flowchart.mmd) — the source-of-truth flow diagram the implementation follows node-by-node
|
||||
- [docs/README.md](docs/README.md) — **index of all documentation** (docs-first, single source of truth, one ADR per decision: [docs/adr/](docs/adr/README.md))
|
||||
- [docs/come-funziona.md](docs/come-funziona.md) — the specification (Italian): how the platform works, with diagrams and traceable requirement IDs; it indexes every per-topic document (flows, architecture, data model, API, security, audit log, operations, decisions and limits, bug history). [docs/processo-di-modifica.md](docs/processo-di-modifica.md) describes how a change goes from the spec to the code
|
||||
- [docs/setup.md](docs/setup.md) — one-time setup (secrets, master key, migrations)
|
||||
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it (local venv vs. Docker+Caddy, dev vs. production TLS)
|
||||
- [docs/running-the-server.md](docs/running-the-server.md) — how to launch it with Docker+Caddy (dev vs. production TLS)
|
||||
- [docs/guida-utente.md](docs/guida-utente.md) — end-user guide to the test UI (Italian)
|
||||
- [docs/guida-admin.md](docs/guida-admin.md) — admin dashboard guide (Italian)
|
||||
|
||||
@@ -51,7 +52,7 @@ domain).
|
||||
|
||||
Python (FastAPI, SQLAlchemy async + Alembic, Argon2 + JWT auth), Electrum
|
||||
protocol for PLM network access (no full node), Docker + Caddy for
|
||||
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack-mvp) for the complete list
|
||||
deployment. See [CLAUDE.md](CLAUDE.md#tech-stack) for the complete list
|
||||
and the reasoning behind each choice.
|
||||
|
||||
## Testing
|
||||
@@ -61,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
232 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
617 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
+15
-1
@@ -10,8 +10,22 @@ def client_ip(request: Request) -> str:
|
||||
Shared by the login/registration throttles (B-33) and the SSE per-IP
|
||||
subscriber cap (B-38) so the two can't drift into different notions of
|
||||
"the client's IP".
|
||||
|
||||
B-54: the *last* element, not the first. A proxy appends the address it saw
|
||||
to any X-Forwarded-For the client already sent, so the first element is
|
||||
attacker-controlled — with the header read from the front, rotating a fake
|
||||
value per request gave every request a fresh identity and turned all three
|
||||
IP-keyed controls above into decoration. The last element is the one written
|
||||
by the hop closest to us, i.e. by our own proxy. Exactly one trusted proxy
|
||||
sits in front of this app (Caddy, see docker-compose.yml, where `app` is
|
||||
only `expose`d on the compose network and never published to the host), so
|
||||
the last element is the real peer. The Caddyfile now also overwrites the
|
||||
header with `header_up X-Forwarded-For {remote_host}`, which collapses it to
|
||||
a single value — belt and braces: either fix alone closes B-54.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
|
||||
if hops:
|
||||
return hops[-1]
|
||||
return request.client.host if request.client else "unknown"
|
||||
+51
-7
@@ -8,11 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.audit.log import write_audit_log
|
||||
from app.auth.security import hash_password
|
||||
from app.auth.security import hash_password_async
|
||||
from app.config import settings
|
||||
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
from app.wallet.hd import derive_user_wif
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB
|
||||
@@ -44,6 +45,7 @@ _CONFIG_FIELDS = (
|
||||
"fee_rate_sat_vb",
|
||||
"rbf_timeout_seconds",
|
||||
"draw_animation_seconds",
|
||||
"draw_confirmations", # B-73 (DAT-62)
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +57,9 @@ class RoundConfigResponse(BaseModel):
|
||||
fee_rate_sat_vb: int
|
||||
rbf_timeout_seconds: int
|
||||
draw_animation_seconds: int
|
||||
draw_confirmations: int # B-73 (DAT-62)
|
||||
paused: bool
|
||||
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
|
||||
|
||||
|
||||
class RoundConfigUpdate(BaseModel):
|
||||
@@ -75,6 +79,7 @@ class RoundConfigUpdate(BaseModel):
|
||||
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
|
||||
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
|
||||
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
|
||||
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
|
||||
|
||||
@field_validator("fee_address")
|
||||
@classmethod
|
||||
@@ -93,6 +98,7 @@ class RoundConfigUpdate(BaseModel):
|
||||
def _config_response(config) -> RoundConfigResponse:
|
||||
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
|
||||
fields["paused"] = config.paused
|
||||
fields["paused_reason"] = config.paused_reason
|
||||
return RoundConfigResponse(**fields)
|
||||
|
||||
|
||||
@@ -128,14 +134,29 @@ async def update_config(
|
||||
return _config_response(config)
|
||||
|
||||
|
||||
class PauseRequest(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
@router.post("/pause", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
async def pause_lottery(
|
||||
body: PauseRequest | None = None, session: AsyncSession = Depends(get_session)
|
||||
) -> RoundConfigResponse:
|
||||
"""Maintenance switch: the round in progress (if any) still closes, draws,
|
||||
and pays out its winner normally — only opening the *next* round is
|
||||
suppressed until /admin/resume is called (rounds/service.py)."""
|
||||
suppressed until /admin/resume is called (rounds/service.py).
|
||||
|
||||
B-78 (DAT-69, API-32): the reason is "operator", except that an automatic reason
|
||||
already in force stays — it is the one that needs a look. The audit row always
|
||||
says `operator` for this call."""
|
||||
config = await get_round_config(session)
|
||||
if not (config.paused and config.paused_reason in AUTOMATIC_REASONS):
|
||||
config.paused_reason = OPERATOR_REASON
|
||||
config.paused = True
|
||||
await write_audit_log(session, "lottery_paused", {})
|
||||
payload = {"reason": OPERATOR_REASON}
|
||||
if body is not None and body.note:
|
||||
payload["note"] = body.note
|
||||
await write_audit_log(session, "lottery_paused", payload)
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
@@ -143,8 +164,12 @@ async def pause_lottery(session: AsyncSession = Depends(get_session)) -> RoundCo
|
||||
@router.post("/resume", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||
async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundConfigResponse:
|
||||
config = await get_round_config(session)
|
||||
# B-78 (AUD-12): the reason the resume took away, so the log says what the operator
|
||||
# agreed to resume (None when the lottery was not paused).
|
||||
lifted = config.paused_reason if config.paused else None
|
||||
config.paused = False
|
||||
await write_audit_log(session, "lottery_resumed", {})
|
||||
config.paused_reason = None
|
||||
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
|
||||
await session.commit()
|
||||
return _config_response(config)
|
||||
|
||||
@@ -217,7 +242,7 @@ async def reset_user_password(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_hash = await hash_password_async(new_password)
|
||||
# B-34: this endpoint exists precisely for the "account compromised" case —
|
||||
# without bumping token_version, whoever was already logged in (the
|
||||
# attacker, if that's who prompted the reset) stayed logged in on their
|
||||
@@ -241,6 +266,9 @@ class AdminRoundResponse(BaseModel):
|
||||
winner_amount_sats: int | None
|
||||
fee_amount_sats: int | None
|
||||
payout_txid: str | None
|
||||
pool_address: str | None # B-80
|
||||
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
|
||||
draw_height: int | None # B-73
|
||||
|
||||
|
||||
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
|
||||
@@ -268,6 +296,9 @@ async def list_rounds(
|
||||
winner_amount_sats=r.winner_amount_sats,
|
||||
fee_amount_sats=r.fee_amount_sats,
|
||||
payout_txid=r.payout_txid,
|
||||
pool_address=r.pool_address or None,
|
||||
server_seed_hash=r.server_seed_hash,
|
||||
draw_height=r.draw_height,
|
||||
)
|
||||
for r in rounds
|
||||
]
|
||||
@@ -408,7 +439,20 @@ async def update_bug_report_status(
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
||||
|
||||
report.status = body.status
|
||||
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
||||
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
||||
# With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the
|
||||
# only accountability there is. Before/after like config_updated, and nothing
|
||||
# written when the status doesn't actually change — re-clicking the status a
|
||||
# report already has isn't an event.
|
||||
if report.status != body.status:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"bug_report_status_changed",
|
||||
{"report_id": report_id, "from": report.status, "to": body.status},
|
||||
user_id=report.user_id,
|
||||
)
|
||||
report.status = body.status
|
||||
await session.commit()
|
||||
|
||||
username = None
|
||||
|
||||
+10
-2
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,6 +21,7 @@ class BetResponse(BaseModel):
|
||||
@router.post("", response_model=BetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bet(
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BetResponse:
|
||||
@@ -34,17 +35,24 @@ async def create_bet(
|
||||
|
||||
async with request.app.state.user_locks.acquire(user.id):
|
||||
try:
|
||||
participant = await place_bet(session, listener.client, user)
|
||||
participant = await place_bet(session, listener.client, user, network=listener)
|
||||
except BetError as exc:
|
||||
# A rejected broadcast isn't the client's fault — it's the network
|
||||
# refusing our transaction, so it answers 502 rather than 400 (B-07).
|
||||
code = (
|
||||
status.HTTP_502_BAD_GATEWAY
|
||||
if exc.code == "broadcast_failed"
|
||||
else status.HTTP_503_SERVICE_UNAVAILABLE # B-88: write lock not granted, nothing sent
|
||||
if exc.code == "service_busy"
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise from_api_error(code, exc) from exc
|
||||
|
||||
if participant.status == "building":
|
||||
# B-76 (TX-20, BET-23): the outcome of the broadcast is uncertain — the tx may
|
||||
# already be on the network — so the bet is accepted for processing, not
|
||||
# confirmed as sent, and the reconciler settles it.
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
return BetResponse(
|
||||
round_id=participant.round_id,
|
||||
bet_txid=participant.bet_txid,
|
||||
|
||||
+46
-10
@@ -1,22 +1,58 @@
|
||||
"""PNG QR codes for PLM addresses.
|
||||
|
||||
Deliberately unauthenticated: the dashboard renders it with a plain `<img>`
|
||||
tag, which cannot carry the bearer token, and the payload is an address the
|
||||
caller already has. What the endpoint must not be is a free CPU amplifier
|
||||
(B-67), so two things bound the work an anonymous caller can ask for:
|
||||
|
||||
- the address is validated for real (bech32 checksum + PLM HRP) via
|
||||
`is_valid_plm_address`, the same check withdrawals and the admin
|
||||
`fee_address` validator use, instead of a shape-only regex that happily
|
||||
rendered a QR for any `plm1`-prefixed junk string;
|
||||
- the render itself is memoized per address and pushed off the event loop, so
|
||||
a repeat request costs a dict lookup and a first one never blocks the
|
||||
scheduler, the listener or any other request.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
|
||||
router = APIRouter(tags=["qr"])
|
||||
|
||||
# PLM P2WPKH addresses: bech32 HRP "plm" + separator + witness program.
|
||||
_ADDRESS_RE = re.compile(r"^plm1[a-z0-9]{10,90}$")
|
||||
# Long enough for any bech32 address, short enough that a multi-kilobyte path
|
||||
# is rejected before embit ever looks at it.
|
||||
_MAX_ADDRESS_LENGTH = 100
|
||||
|
||||
# Bounded on purpose: valid addresses are cheap to generate, so an unbounded
|
||||
# cache would just move the amplification from CPU to memory.
|
||||
_CACHE_SIZE = 512
|
||||
|
||||
|
||||
@lru_cache(maxsize=_CACHE_SIZE)
|
||||
def _render_png(address: str) -> bytes:
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@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")
|
||||
if len(address) > _MAX_ADDRESS_LENGTH or not is_valid_plm_address(address):
|
||||
raise http_error(400, "invalid_address", "not a valid PLM bech32 address")
|
||||
|
||||
image = qrcode.make(address)
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return Response(content=buf.getvalue(), media_type="image/png")
|
||||
png = await run_in_threadpool(_render_png, address)
|
||||
# An address' QR never changes; let the browser stop asking for it.
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "private, max-age=86400, immutable"},
|
||||
)
|
||||
+154
-10
@@ -9,13 +9,14 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.client_ip import client_ip
|
||||
from app.api.errors import http_error
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_optional_user
|
||||
from app.db.models import RoundParticipant, User
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
||||
from app.rounds.service import get_active_round, winner_share
|
||||
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
|
||||
|
||||
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
||||
|
||||
@@ -89,11 +90,26 @@ class CurrentRoundResponse(BaseModel):
|
||||
server_time: str
|
||||
round_id: int | None = None
|
||||
status: str | None = None
|
||||
# B-80 (RND-35): this round's own pool address, so anyone can verify its money on
|
||||
# an explorer; null when there is no active round.
|
||||
pool_address: str | None = None
|
||||
opened_at: str | None = None
|
||||
closes_at: str | None = None
|
||||
# B-65: confirmed participants only — the ones the draw actually picks from and
|
||||
# whose sats are actually in the pool. The pending_* pair below is the same
|
||||
# confirmed/in-flight split the balance already exposes (see
|
||||
# wallet/balance.py's balance_sats vs pending_balance_sats), and for the same
|
||||
# reason: the authoritative number must be the one that will be paid, while the
|
||||
# player who just bet still needs to see their own bet somewhere.
|
||||
participant_count: int = 0
|
||||
bet_amount_sats: int
|
||||
jackpot_sats: int = 0
|
||||
# Inclusive of bets still building/broadcast, exactly like pending_balance_sats
|
||||
# is inclusive of unconfirmed change — not deltas. Equal to the confirmed
|
||||
# figures above when nothing is in flight, which is what has_pending_bets says.
|
||||
pending_participant_count: int = 0
|
||||
pending_jackpot_sats: int = 0
|
||||
has_pending_bets: bool = False
|
||||
draw_animation_seconds: int
|
||||
winner_user_id: int | None = None
|
||||
winner_amount_sats: int | None = None
|
||||
@@ -103,8 +119,22 @@ class CurrentRoundResponse(BaseModel):
|
||||
# waiting for a block" rather than a countdown implying a bounded wait — this
|
||||
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
|
||||
draw_waiting_since: str | None = None
|
||||
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
|
||||
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
|
||||
# revealed).
|
||||
server_seed_hash: str | None = None
|
||||
participants_hash: str | None = None
|
||||
draw_height: int | None = None
|
||||
draw_confirmations: int | None = None
|
||||
chain_tip_height: int | None = None
|
||||
lottery_paused: bool = False
|
||||
paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused
|
||||
# B-66: false while the instance is missing configuration a round cannot run
|
||||
# without (today: fee_address) — no round will open until it's set, so this is
|
||||
# the difference between "wait, the next round is coming" and "nothing is coming
|
||||
# until the operator finishes setting this up". Distinct from lottery_paused,
|
||||
# which is a deliberate operator action rather than an unmet prerequisite.
|
||||
lottery_configured: bool = True
|
||||
user_played: bool = False
|
||||
|
||||
|
||||
@@ -126,23 +156,40 @@ async def current_round(
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
paused_reason=config.paused_reason,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
)
|
||||
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
) or 0
|
||||
# The pool is the sum of what the participants' bets actually paid into the pool
|
||||
# address — each one is already net of that bet's network fee. Deriving it from
|
||||
# participant_count * the *current* bet_amount_sats instead overstated it, and
|
||||
# silently changed the advertised jackpot of a round in progress whenever an
|
||||
# operator edited the bet amount (B-11).
|
||||
pool_amount_sats = await session.scalar(
|
||||
select(func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0)).where(
|
||||
RoundParticipant.round_id == round_.id
|
||||
#
|
||||
# Split confirmed from in-flight (B-65): the draw only picks from confirmed
|
||||
# participants and the payout only spends their sats, so counting every row
|
||||
# advertised a jackpot larger than the one that would be paid, and made a
|
||||
# participant appear and then vanish again if their bet was later abandoned.
|
||||
counts = (
|
||||
await session.execute(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0),
|
||||
func.count().filter(RoundParticipant.status == "confirmed"),
|
||||
func.coalesce(
|
||||
func.sum(RoundParticipant.bet_amount_sats).filter(
|
||||
RoundParticipant.status == "confirmed"
|
||||
),
|
||||
0,
|
||||
),
|
||||
).where(RoundParticipant.round_id == round_.id)
|
||||
)
|
||||
) or 0
|
||||
).one()
|
||||
all_count, all_pool_sats, participant_count, pool_amount_sats = counts
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
closes_at = opened_at + timedelta(seconds=config.round_duration_seconds)
|
||||
# B-61: from the round's own duration — the countdown clients are watching must
|
||||
# not jump because an operator edited the config mid-round.
|
||||
closes_at = round_deadline(round_)
|
||||
|
||||
# Lets the frontend show the personalized win/lose reveal only to players in
|
||||
# this round — everyone else (not logged in, or logged in but didn't bet)
|
||||
@@ -171,18 +218,115 @@ async def current_round(
|
||||
server_time=datetime.now(timezone.utc).isoformat(),
|
||||
round_id=round_.id,
|
||||
status=round_.status,
|
||||
pool_address=round_.pool_address or None,
|
||||
opened_at=opened_at.isoformat(),
|
||||
closes_at=closes_at.isoformat(),
|
||||
participant_count=participant_count,
|
||||
bet_amount_sats=config.bet_amount_sats,
|
||||
jackpot_sats=jackpot_sats,
|
||||
pending_participant_count=all_count,
|
||||
pending_jackpot_sats=winner_share(all_pool_sats),
|
||||
has_pending_bets=all_count > participant_count,
|
||||
draw_animation_seconds=config.draw_animation_seconds,
|
||||
winner_user_id=round_.winner_user_id,
|
||||
winner_amount_sats=round_.winner_amount_sats,
|
||||
draw_block_height=round_.draw_block_height,
|
||||
draw_block_hash=round_.draw_block_hash,
|
||||
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
|
||||
server_seed_hash=round_.server_seed_hash,
|
||||
participants_hash=round_.participants_hash,
|
||||
draw_height=round_.draw_height,
|
||||
draw_confirmations=round_.draw_confirmations,
|
||||
chain_tip_height=chain_tip_height,
|
||||
lottery_paused=config.paused,
|
||||
paused_reason=config.paused_reason,
|
||||
lottery_configured=rounds_can_open(config),
|
||||
user_played=user_played,
|
||||
)
|
||||
|
||||
|
||||
class ProofParticipant(BaseModel):
|
||||
draw_position: int
|
||||
txid: str
|
||||
vout: int
|
||||
address: str
|
||||
amount_sats: int
|
||||
status: str
|
||||
|
||||
|
||||
class RoundProofResponse(BaseModel):
|
||||
round_id: int
|
||||
status: str
|
||||
algorithm: str
|
||||
pool_address: str | None
|
||||
server_seed_hash: str | None
|
||||
server_seed: str | None
|
||||
draw_height: int | None
|
||||
draw_confirmations: int
|
||||
participants: list[ProofParticipant]
|
||||
participants_hash: str | None
|
||||
draw_block_hash: str | None
|
||||
seed: str | None
|
||||
winner_index: int | None
|
||||
winner_address: str | None
|
||||
payout_txid: str | None
|
||||
|
||||
|
||||
@router.get("/{round_id}/proof", response_model=RoundProofResponse)
|
||||
async def round_proof(round_id: int, session: AsyncSession = Depends(get_session)) -> RoundProofResponse:
|
||||
"""B-73 (RND-37, API-45): everything needed to recompute a round's winner without
|
||||
trusting the server — public, unauthenticated, for a round in any state. A field that
|
||||
does not exist yet is null (or an empty list).
|
||||
|
||||
`server_seed` is the plaintext column, which is written only by the reveal (the same
|
||||
transaction that stores the winner, or the close of a round without participants), so
|
||||
there is no state of the round in which this endpoint can return it early.
|
||||
"""
|
||||
round_ = await session.get(Round, round_id)
|
||||
if round_ is None:
|
||||
raise http_error(404, "round_not_found", "round not found")
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(RoundParticipant, User.address)
|
||||
.join(User, User.id == RoundParticipant.user_id)
|
||||
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
participants = [
|
||||
ProofParticipant(
|
||||
draw_position=p.draw_position,
|
||||
txid=p.bet_txid,
|
||||
vout=p.bet_vout,
|
||||
address=address,
|
||||
amount_sats=p.bet_amount_sats,
|
||||
status=p.status,
|
||||
)
|
||||
for p, address in rows
|
||||
]
|
||||
|
||||
seed_decimal = round_.seed_int
|
||||
winner_index = None
|
||||
winner_address = None
|
||||
if seed_decimal is not None and participants:
|
||||
winner_index = int(seed_decimal) % len(participants)
|
||||
winner_address = participants[winner_index].address
|
||||
|
||||
return RoundProofResponse(
|
||||
round_id=round_.id,
|
||||
status=round_.status,
|
||||
algorithm="commit-reveal-v1" if round_.server_seed_hash else "block-hash-v0",
|
||||
pool_address=round_.pool_address or None,
|
||||
server_seed_hash=round_.server_seed_hash,
|
||||
server_seed=round_.server_seed,
|
||||
draw_height=round_.draw_height,
|
||||
draw_confirmations=round_.draw_confirmations,
|
||||
participants=participants,
|
||||
participants_hash=round_.participants_hash,
|
||||
draw_block_hash=round_.draw_block_hash,
|
||||
seed=seed_decimal,
|
||||
winner_index=winner_index,
|
||||
winner_address=winner_address,
|
||||
payout_txid=round_.payout_txid,
|
||||
)
|
||||
@@ -6,7 +6,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.errors import http_error
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
hash_password_async,
|
||||
verify_password_async,
|
||||
)
|
||||
from app.db.models import Round, RoundParticipant, User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.balance import compute_pending_balance
|
||||
@@ -59,7 +64,7 @@ async def change_password(
|
||||
"""Self-service password change — requires the current password, unlike the
|
||||
admin-only /admin/users/{id}/reset-password (which is for a user who's
|
||||
actually locked out and can't provide it)."""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
if not await verify_password_async(body.current_password, user.password_hash):
|
||||
raise http_error(
|
||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||
)
|
||||
@@ -71,7 +76,7 @@ async def change_password(
|
||||
minimum=MIN_PASSWORD_LENGTH,
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.password_hash = await hash_password_async(body.new_password)
|
||||
# B-34: bumping token_version invalidates every token issued before this
|
||||
# point — including this very request's own bearer token, and any an
|
||||
# attacker who knew the old password might be holding. A fresh token is
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -27,6 +27,7 @@ class WithdrawalResponse(BaseModel):
|
||||
async def create_withdrawal(
|
||||
body: WithdrawalRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WithdrawalResponse:
|
||||
@@ -41,16 +42,22 @@ async def create_withdrawal(
|
||||
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
|
||||
session, listener.client, user, body.external_address, body.amount_sats, network=listener
|
||||
)
|
||||
except WithdrawalError as exc:
|
||||
code = (
|
||||
status.HTTP_502_BAD_GATEWAY # the network refused it, not the caller (B-07)
|
||||
if exc.code == "broadcast_failed"
|
||||
else status.HTTP_503_SERVICE_UNAVAILABLE # B-88: write lock not granted, nothing sent
|
||||
if exc.code == "service_busy"
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise from_api_error(code, exc) from exc
|
||||
|
||||
if withdrawal.status == "building":
|
||||
# B-76 (TX-20, WDR-16): uncertain broadcast — accepted for processing, the
|
||||
# reconciler decides whether the transaction left.
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
return WithdrawalResponse(
|
||||
txid=withdrawal.txid,
|
||||
amount_requested_sats=withdrawal.amount_requested_sats,
|
||||
|
||||
+137
-2
@@ -1,6 +1,15 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# B-56: bounds on _buckets, which is keyed by strings the caller chooses.
|
||||
# 50k entries is a few MB at ~100 bytes each — far more than any real deployment's
|
||||
# active attacker set, and small enough that filling it isn't a memory attack.
|
||||
_MAX_BUCKETS = 50_000
|
||||
# How often record_failure sweeps out spent entries. Cheap (one pass over a dict
|
||||
# that the sweep itself keeps small) and off the request's critical path in the
|
||||
# normal case, since a successful login records no failure at all.
|
||||
_SWEEP_INTERVAL_SECONDS = 60.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Bucket:
|
||||
@@ -29,22 +38,65 @@ class RateLimiter:
|
||||
base_delay: float = 2.0,
|
||||
max_delay: float = 300.0,
|
||||
decay_seconds: float = 900.0,
|
||||
max_buckets: int = _MAX_BUCKETS,
|
||||
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._base_delay = base_delay
|
||||
self._max_delay = max_delay
|
||||
self._decay_seconds = decay_seconds
|
||||
self._max_buckets = max_buckets
|
||||
self._sweep_interval_seconds = sweep_interval_seconds
|
||||
self._buckets: dict[str, _Bucket] = {}
|
||||
self._last_sweep_at = time.monotonic()
|
||||
|
||||
def _is_spent(self, bucket: _Bucket, now: float) -> bool:
|
||||
"""Nothing left to remember: the lockout has expired *and* the failure count
|
||||
would decay to zero on the next failure anyway. Dropping such a bucket is
|
||||
indistinguishable from keeping it — which is what makes eviction safe."""
|
||||
return bucket.locked_until <= now and now - bucket.last_failure_at > self._decay_seconds
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
"""B-56: the dict was keyed by attacker-chosen strings (any username, and via
|
||||
B-54 any IP) and only ever grew — `decay_seconds` aged a bucket's counter but
|
||||
never removed the entry, so hammering login with random usernames was an
|
||||
unbounded memory leak.
|
||||
|
||||
Spent buckets go first, and they carry no information, so that alone keeps
|
||||
the dict at the size of the genuinely active attack surface. The hard cap
|
||||
below is the backstop for a burst faster than the sweep interval: it evicts
|
||||
the entries closest to expiry, i.e. the ones whose loss buys an attacker the
|
||||
least — never the freshest lockouts, which are the ones actually holding an
|
||||
attack back."""
|
||||
for key in [k for k, b in self._buckets.items() if self._is_spent(b, now)]:
|
||||
del self._buckets[key]
|
||||
self._last_sweep_at = now
|
||||
|
||||
excess = len(self._buckets) - self._max_buckets
|
||||
if excess > 0:
|
||||
by_expiry = sorted(
|
||||
self._buckets.items(), key=lambda item: (item[1].locked_until, item[1].last_failure_at)
|
||||
)
|
||||
for key, _ in by_expiry[:excess]:
|
||||
del self._buckets[key]
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
bucket = self._buckets.get(key)
|
||||
if bucket is None:
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - time.monotonic()
|
||||
now = time.monotonic()
|
||||
if self._is_spent(bucket, now):
|
||||
# Self-cleaning read path: a key that's merely being probed never
|
||||
# accumulates an entry that outlives its own usefulness.
|
||||
del self._buckets[key]
|
||||
return 0.0
|
||||
remaining = bucket.locked_until - now
|
||||
return remaining if remaining > 0 else 0.0
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._buckets) > self._max_buckets:
|
||||
self._prune(now)
|
||||
bucket = self._buckets.setdefault(key, _Bucket())
|
||||
if bucket.failures and now - bucket.last_failure_at > self._decay_seconds:
|
||||
bucket.failures = 0
|
||||
@@ -58,6 +110,84 @@ class RateLimiter:
|
||||
self._buckets.pop(key, None)
|
||||
|
||||
|
||||
class RollingQuota:
|
||||
"""How many times a key may do something in a rolling window — as opposed to
|
||||
RateLimiter above, which punishes *failures* with a growing delay.
|
||||
|
||||
B-58: registration was throttled with the failure limiter, and recorded a
|
||||
"failure" on every attempt, successful ones included. Five legitimate signups
|
||||
from one shared or NAT address locked the sixth real user out for up to 600s,
|
||||
with the backoff doubling from there — while an attacker sidestepped the whole
|
||||
thing through B-54. The intent (bound how many accounts one source can create)
|
||||
is right; failure backoff is the wrong instrument for it, since nothing here is
|
||||
a failed guess at a secret. A quota says exactly what is meant: this many
|
||||
accounts per source per window, and the answer to the one over it is "not yet",
|
||||
with an accurate wait rather than a punishment that grows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int,
|
||||
window_seconds: float,
|
||||
max_keys: int = _MAX_BUCKETS,
|
||||
sweep_interval_seconds: float = _SWEEP_INTERVAL_SECONDS,
|
||||
) -> None:
|
||||
self._limit = limit
|
||||
self._window_seconds = window_seconds
|
||||
self._max_keys = max_keys
|
||||
self._sweep_interval_seconds = sweep_interval_seconds
|
||||
self._events: dict[str, list[float]] = {}
|
||||
self._last_sweep_at = time.monotonic()
|
||||
|
||||
def _live_events(self, key: str, now: float) -> list[float]:
|
||||
"""The key's events still inside the window, pruned in place."""
|
||||
events = self._events.get(key)
|
||||
if events is None:
|
||||
return []
|
||||
cutoff = now - self._window_seconds
|
||||
while events and events[0] <= cutoff:
|
||||
events.pop(0)
|
||||
if not events:
|
||||
del self._events[key]
|
||||
return events
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
# Same bound as RateLimiter (B-56): the keys are caller-chosen, so the dict
|
||||
# needs both a sweep and a hard cap. Eviction order is likewise "closest to
|
||||
# leaving the window first" — dropping a key with room left in its quota
|
||||
# changes nothing, dropping a full one hands out free accounts.
|
||||
for key in list(self._events):
|
||||
self._live_events(key, now)
|
||||
self._last_sweep_at = now
|
||||
|
||||
excess = len(self._events) - self._max_keys
|
||||
if excess > 0:
|
||||
by_oldest = sorted(self._events.items(), key=lambda item: item[1][-1])
|
||||
for key, _ in by_oldest[:excess]:
|
||||
del self._events[key]
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
"""Seconds until this key may act again — 0 while it is under quota."""
|
||||
now = time.monotonic()
|
||||
events = self._live_events(key, now)
|
||||
if len(events) < self._limit:
|
||||
return 0.0
|
||||
return events[0] + self._window_seconds - now
|
||||
|
||||
def record(self, key: str) -> None:
|
||||
"""Counts one *completed* action. Attempts that create nothing (a taken
|
||||
username, a validation error) deliberately don't consume the quota — the
|
||||
limit is on accounts that exist, not on requests."""
|
||||
now = time.monotonic()
|
||||
if now - self._last_sweep_at >= self._sweep_interval_seconds or len(self._events) > self._max_keys:
|
||||
self._prune(now)
|
||||
self._events.setdefault(key, []).append(now)
|
||||
|
||||
|
||||
_REGISTRATIONS_PER_IP = 5
|
||||
_REGISTRATION_WINDOW_SECONDS = 3600.0
|
||||
|
||||
|
||||
class AuthRateLimiters:
|
||||
"""The three throttles B-33 needs, bundled so they can live on `app.state`
|
||||
(like `UserLocks`, see app/tx/locks.py) rather than as module globals.
|
||||
@@ -72,4 +202,9 @@ class AuthRateLimiters:
|
||||
def __init__(self) -> None:
|
||||
self.login = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
|
||||
self.login_ip = RateLimiter(threshold=20, base_delay=2.0, max_delay=300.0)
|
||||
self.register_ip = RateLimiter(threshold=5, base_delay=5.0, max_delay=600.0)
|
||||
# B-58: a quota, not failure backoff — 5 accounts per IP per hour. The one
|
||||
# over it waits only until the oldest of the five ages out, and a busy NAT
|
||||
# is slowed rather than locked out for progressively longer.
|
||||
self.register_ip = RollingQuota(
|
||||
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
|
||||
)
|
||||
+37
-11
@@ -1,13 +1,19 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.client_ip import client_ip as _client_ip
|
||||
from app.api.errors import http_error
|
||||
from app.auth.rate_limit import AuthRateLimiters
|
||||
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
hash_password_async,
|
||||
verify_password_async,
|
||||
)
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session
|
||||
from app.wallet.hd import derive_user_address
|
||||
@@ -25,10 +31,11 @@ def _rate_limiters(request: Request) -> AuthRateLimiters:
|
||||
# still catches that). The IP limiter's threshold is deliberately higher
|
||||
# than the username one: a single account should lock out fast, but a
|
||||
# shared/NAT IP hosting several genuine users shouldn't be punished for one
|
||||
# of them mistyping a password a few times. Registration gets its own,
|
||||
# coarser limiter, IP-only — no username exists yet to key on — mainly to
|
||||
# bound how many accounts one IP can spin up (B-31), not to protect a
|
||||
# secret. Lives on app.state (see AuthRateLimiters) rather than a module
|
||||
# of them mistyping a password a few times. Registration gets its own
|
||||
# instrument entirely: a per-IP *quota* on accounts created (B-58), since
|
||||
# bounding how many accounts one source can spin up is not the same problem
|
||||
# as slowing down guesses at a secret, and failure backoff only punished the
|
||||
# honest signups. Lives on app.state (see AuthRateLimiters) rather than a module
|
||||
# global so each app instance gets its own, isolated throttle state.
|
||||
if not hasattr(request.app.state, "auth_rate_limiters"):
|
||||
request.app.state.auth_rate_limiters = AuthRateLimiters()
|
||||
@@ -65,18 +72,36 @@ async def register(
|
||||
) -> TokenResponse:
|
||||
limiters = _rate_limiters(request)
|
||||
ip_key = f"ip:{_client_ip(request)}"
|
||||
# B-58: checked before the Argon2 hash below, so an IP that's out of quota
|
||||
# costs nothing to turn away. Recorded only once an account actually exists —
|
||||
# see the successful path below.
|
||||
retry_after = limiters.register_ip.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
limiters.register_ip.record_failure(ip_key)
|
||||
|
||||
existing = await session.scalar(select(User).where(User.username == body.username))
|
||||
# B-57: case-insensitive, matching the unique index on lower(username) — and
|
||||
# matching the throttle key below, which has always been lowercased.
|
||||
existing = await session.scalar(
|
||||
select(User).where(func.lower(User.username) == body.username.lower())
|
||||
)
|
||||
if existing is not None:
|
||||
raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken")
|
||||
|
||||
password_hash = hash_password(body.password)
|
||||
password_hash = await hash_password_async(body.password)
|
||||
|
||||
for _ in range(_MAX_REGISTER_RETRIES):
|
||||
# B-88: the max(derivation_index) read and the insert that uses it are one
|
||||
# transaction under the write lock — with a plain BEGIN two concurrent
|
||||
# registrations both read the same maximum and one of them failed on the
|
||||
# unique index. A lock that cannot be had is a "try again", not a 500.
|
||||
try:
|
||||
await begin_immediate(session)
|
||||
except OperationalError as exc:
|
||||
raise http_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"service_busy",
|
||||
"the service is busy right now, nothing was sent — please try again",
|
||||
) from exc
|
||||
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)
|
||||
@@ -101,6 +126,7 @@ async def register(
|
||||
) from exc
|
||||
continue
|
||||
await session.refresh(user)
|
||||
limiters.register_ip.record(ip_key) # B-58: one account created, one slot used
|
||||
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(user.id, user.token_version), address=user.address
|
||||
@@ -129,8 +155,8 @@ async def login(
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
|
||||
user = await session.scalar(select(User).where(User.username == body.username))
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
user = await session.scalar(select(User).where(func.lower(User.username) == body.username.lower()))
|
||||
if user is None or not await verify_password_async(body.password, user.password_hash):
|
||||
# Same code path (and therefore the same response) whether the username
|
||||
# doesn't exist or the password is wrong — no enumeration oracle here.
|
||||
limiters.login.record_failure(username_key)
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -39,6 +40,28 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# --- B-55: the two Argon2 calls above must never run on the event loop ----------
|
||||
# Argon2 is deliberately expensive — tens of milliseconds of CPU per call, by
|
||||
# design. Called straight from an async handler that stalls the *whole* process
|
||||
# for that long: every other request, and all six background tasks (scheduler,
|
||||
# confirmation poller, RBF bumper, listener, both reconcilers). A burst of
|
||||
# unauthenticated login attempts was therefore a cheap way to delay draws and
|
||||
# confirmations, not just to slow down logins. The threadpool keeps the cost
|
||||
# where it belongs — on a worker thread, with the loop free to run everything
|
||||
# else meanwhile.
|
||||
#
|
||||
# The synchronous functions stay: they're what the wrappers call, and what tests
|
||||
# and scripts (no running loop) use directly. Every async caller must use these.
|
||||
|
||||
|
||||
async def hash_password_async(password: str) -> str:
|
||||
return await run_in_threadpool(hash_password, password)
|
||||
|
||||
|
||||
async def verify_password_async(password: str, password_hash: str) -> bool:
|
||||
return await run_in_threadpool(verify_password, password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(user_id: int, token_version: int = 0) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
# "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued
|
||||
|
||||
+152
-17
@@ -1,32 +1,68 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
|
||||
from app.tx.versions import delete_versions, record_version
|
||||
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.hd import derive_pool_address, derive_user_key
|
||||
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
|
||||
from app.wallet.hd import derive_user_key
|
||||
from app.wallet.psbt_builder import (
|
||||
MAX_PARTICIPANTS_PER_ROUND,
|
||||
RECIPIENT_VOUT,
|
||||
BuiltTransaction,
|
||||
InsufficientFundsError,
|
||||
Utxo,
|
||||
build_signed_transaction,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BetError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
|
||||
class _RoundClosedDuringBuild(Exception):
|
||||
"""Internal signal (B-53): the round stopped accepting bets while this one was
|
||||
being built. Never leaves place_bet — it becomes a `round_closing` BetError."""
|
||||
|
||||
|
||||
async def place_bet(
|
||||
session: AsyncSession, client: ElectrumClient, user: User, network: TransactionNetwork | None = None
|
||||
) -> RoundParticipant:
|
||||
"""`network` (the ElectrumListener) is what verifies a refused broadcast and fans an
|
||||
accepted one out (B-76); without it a refusal can never be proven, so it never
|
||||
releases anything."""
|
||||
# B-88 (DAT-74, BET-11): take SQLite's write lock before the first read, so the
|
||||
# balance, the free UTXOs, the participants and the round's status read below are
|
||||
# the state the phase-1 write lands on — a plain BEGIN only starts at the first
|
||||
# write, leaving every read before it outside any transaction. Failing to get the
|
||||
# lock within busy_timeout is not a conflict about this bet: nothing has been
|
||||
# built or broadcast yet, so the player is simply asked to retry.
|
||||
try:
|
||||
await begin_immediate(session)
|
||||
except OperationalError as exc:
|
||||
raise _service_busy() from exc
|
||||
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
if round_ is None:
|
||||
raise BetError("no_round_open", "no round open right now, please try again shortly")
|
||||
|
||||
config = await get_round_config(session)
|
||||
if not round_accepts_bets(round_, config.round_duration_seconds):
|
||||
if not round_accepts_bets(round_):
|
||||
raise BetError("round_closing", "the current round is closing, please try again shortly")
|
||||
|
||||
already_playing = await session.scalar(
|
||||
@@ -37,6 +73,31 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
if already_playing is not None:
|
||||
raise BetError("already_betting", "you already have an active bet in the current round")
|
||||
|
||||
# B-52: "this round can always be paid out" is an invariant, and this is where it
|
||||
# gets enforced — before any of this user's money moves. The payout has to spend
|
||||
# one pool UTXO per bet, so a round that grew past what a single payout
|
||||
# transaction may spend was unpayable: it stayed "paying_out" retrying forever,
|
||||
# and because no new round may open while one is active, the whole lottery
|
||||
# stopped. Refusing the bet costs the player one round of waiting; accepting it
|
||||
# cost everyone the platform. Counted over every participant row, not just the
|
||||
# confirmed ones: a bet that later fails frees a slot, so counting them all is
|
||||
# the conservative direction.
|
||||
participant_count = await session.scalar(
|
||||
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||
)
|
||||
if participant_count >= MAX_PARTICIPANTS_PER_ROUND:
|
||||
raise BetError(
|
||||
"round_full",
|
||||
f"this round already has its maximum of {MAX_PARTICIPANTS_PER_ROUND} players, "
|
||||
"wait for the next one",
|
||||
max_participants=MAX_PARTICIPANTS_PER_ROUND,
|
||||
)
|
||||
|
||||
if not round_.pool_address:
|
||||
# B-80: open_new_round_if_needed always writes it; a row without one cannot
|
||||
# take money that would then have nowhere verifiable to go.
|
||||
raise RuntimeError(f"round {round_.id} has no pool address")
|
||||
|
||||
bet_amount = config.bet_amount_sats
|
||||
|
||||
unspent = (
|
||||
@@ -56,7 +117,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
signing_key=user_key,
|
||||
from_script=from_script,
|
||||
utxos=utxos,
|
||||
to_address=derive_pool_address(),
|
||||
to_address=round_.pool_address, # B-80: this round's own pool address
|
||||
amount_sats=bet_amount,
|
||||
change_address=user.address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
@@ -83,24 +144,90 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
user_id=user.id,
|
||||
bet_amount_sats=built.recipient_sats,
|
||||
bet_txid=built.txid,
|
||||
bet_vout=RECIPIENT_VOUT, # B-73 (DAT-63)
|
||||
broadcast_at=broadcast_at,
|
||||
status="building",
|
||||
)
|
||||
session.add(participant)
|
||||
pending = _pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)
|
||||
session.add(pending)
|
||||
await session.commit()
|
||||
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
|
||||
|
||||
# B-53: the deadline check at the top of this function happened before the UTXO
|
||||
# scan and the signing above, so re-check it here against the clock as it is now —
|
||||
# a slow build must not sneak a bet past the round's deadline.
|
||||
#
|
||||
# And then the part the clock can't cover: a compare-and-set on the round's own
|
||||
# row, in the *same* transaction as the participant insert. The scheduler flips
|
||||
# "open" -> "closing" in a transaction of its own and only counts in-flight
|
||||
# participants afterwards, so without this a bet could commit its "building" row
|
||||
# in between and be paid into the pool while the round drew and paid out without
|
||||
# it — money credited to no round, no participant and no refund path. The UPDATE
|
||||
# takes SQLite's write lock, so the two transactions can no longer interleave:
|
||||
# either this commits first and the scheduler's subsequent in-flight count sees
|
||||
# the row, or the flip commits first and this matches zero rows and refuses the
|
||||
# bet before anything is broadcast.
|
||||
try:
|
||||
if not round_accepts_bets(round_):
|
||||
raise _RoundClosedDuringBuild
|
||||
guard = await session.execute(
|
||||
update(Round)
|
||||
.where(Round.id == round_.id, Round.status == "open")
|
||||
.values(status="open")
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if guard.rowcount != 1:
|
||||
raise _RoundClosedDuringBuild
|
||||
await session.commit()
|
||||
except _RoundClosedDuringBuild as exc:
|
||||
# Nothing has been broadcast yet, so the rollback undoes phase 1 entirely —
|
||||
# the UTXOs stay unspent and no participant row survives.
|
||||
await session.rollback()
|
||||
raise BetError(
|
||||
"round_closing", "the current round is closing, please try again shortly"
|
||||
) from exc
|
||||
except OperationalError as exc:
|
||||
# B-88: SQLite has no write-snapshot conflicts — an OperationalError here is
|
||||
# the lock wait (busy_timeout) expiring, which says nothing about the round.
|
||||
# Same rollback, but the honest answer is "busy", not "closing".
|
||||
await session.rollback()
|
||||
raise _service_busy() from exc
|
||||
|
||||
# --- Phase 2: broadcast, then promote both rows to their live state ---------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
# The node refused it (fee too low, dust, mempool conflict, or simply an
|
||||
# unreachable server) — nothing is on-chain, so undo phase 1 completely and
|
||||
# give the user a translatable failure instead of a bare 500 (B-07).
|
||||
await _release_failed_bet(session, participant, pending, built, user.id, str(exc))
|
||||
raise BetError("broadcast_failed", f"the network refused the transaction: {exc}") from exc
|
||||
# B-76 (TX-04, TX-20): only a *verified* refusal undoes phase 1. A timeout, a
|
||||
# dropped connection or a refusal nobody could verify leaves the tx possibly on the
|
||||
# network, and releasing the coins then sent the bet's sats into the pool with no
|
||||
# participant behind them — so those rows stay "building" for the reconciler.
|
||||
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
|
||||
if verdict.outcome is BroadcastOutcome.REFUSED:
|
||||
# The network refused it (fee too low, dust, mempool conflict) and no server
|
||||
# knows it — nothing is on-chain, so undo phase 1 completely and give the user
|
||||
# a translatable failure instead of a bare 500 (B-07).
|
||||
try:
|
||||
await _release_failed_bet(session, participant, pending, built, user.id, verdict.reason)
|
||||
except OperationalError:
|
||||
# B-88: the database is busy, so the release could not be written. The
|
||||
# rows stay "building" — the state phase 1 committed — and the
|
||||
# reconciler resolves them against the chain.
|
||||
await session.rollback()
|
||||
logger.error("could not release the failed bet %s: database busy, left to the reconciler", built.txid)
|
||||
raise BetError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
|
||||
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
|
||||
logger.warning("bet %s: broadcast outcome uncertain (%s), left 'building' for the reconciler", built.txid, verdict.reason)
|
||||
await session.refresh(participant) # reports the status the DB really holds: building
|
||||
broadcaster.publish() # an in-flight bet counts towards the round's figures
|
||||
return participant
|
||||
|
||||
# B-88: after the broadcast nothing may be undone. If the lock cannot be had the
|
||||
# rows stay "building" (the tx is on the network) and the reconciler promotes
|
||||
# them; the caller still gets the participant, with the status the DB holds.
|
||||
try:
|
||||
await begin_immediate(session)
|
||||
except OperationalError:
|
||||
await session.rollback()
|
||||
logger.error("bet %s broadcast but the database is busy: left 'building' for the reconciler", built.txid)
|
||||
await session.refresh(participant) # reports the status the DB really holds
|
||||
return participant
|
||||
participant.status = "broadcast"
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
@@ -110,7 +237,6 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
||||
user_id=user.id,
|
||||
round_id=round_.id,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(participant)
|
||||
broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch
|
||||
@@ -128,6 +254,7 @@ async def _release_failed_bet(
|
||||
"""Undo phase 1 after a failed broadcast: free the UTXOs the build reserved, drop
|
||||
the two rows, and restore the balance. Same shape as what the reconciler does for
|
||||
a tx that turns out never to have made it onto the chain."""
|
||||
await begin_immediate(session) # B-88
|
||||
for spent in built.spent_utxos:
|
||||
row = await session.scalar(
|
||||
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
|
||||
@@ -135,6 +262,7 @@ async def _release_failed_bet(
|
||||
if row is not None:
|
||||
row.spent_txid = None
|
||||
await session.delete(participant)
|
||||
await delete_versions(session, pending.id) # B-77 (DAT-38): the versions go with the row
|
||||
await session.delete(pending)
|
||||
await recompute_balance(session, user_id)
|
||||
await write_audit_log(
|
||||
@@ -151,6 +279,13 @@ async def _release_failed_bet(
|
||||
broadcaster.publish()
|
||||
|
||||
|
||||
def _service_busy() -> BetError:
|
||||
return BetError(
|
||||
"service_busy",
|
||||
"the service is busy right now, nothing was sent — please try again",
|
||||
)
|
||||
|
||||
|
||||
def _pending_transaction(
|
||||
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
|
||||
) -> PendingTransaction:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
|
||||
@@ -5,6 +7,14 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# than being shipped by accident.
|
||||
MIN_JWT_SECRET_LENGTH = 32
|
||||
|
||||
# B-84 (ELC-19, ELC-14): fallback servers needed besides the primary. With fewer than
|
||||
# three servers, a corroboration (which never asks the active one) cannot have two
|
||||
# independent answers — one server would decide, or none would.
|
||||
MIN_QUORUM_FALLBACK_SERVERS = 2
|
||||
MIN_QUORUM_RESPONSES = 2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
@@ -21,6 +31,15 @@ class Settings(BaseSettings):
|
||||
# connection, which makes a single hardcoded server the platform's biggest
|
||||
# single point of failure. Parsed by electrum.client.parse_endpoints.
|
||||
electrum_fallback_servers: str = ""
|
||||
# B-84 (ELC-03): SHA-256 (hex) of the primary server's DER certificate. Empty = not
|
||||
# pinned. Fallback servers carry their own `:pin=<sha256>` in the list above.
|
||||
electrum_cert_pin: str = ""
|
||||
# B-84 (ELC-19): development only. Lets the server start with fewer than
|
||||
# MIN_QUORUM_FALLBACK_SERVERS fallback servers, and corroboration then follows the
|
||||
# reduced rule (no other server: passes; otherwise a strict majority of however
|
||||
# many answer, even one). Never for production: it is exactly the single-server
|
||||
# trust the quorum exists to remove.
|
||||
allow_no_quorum: bool = False
|
||||
|
||||
xprv_encryption_key: str = ""
|
||||
master_key_path: str = "./master.xprv.enc"
|
||||
@@ -79,3 +98,33 @@ def validate_runtime_secrets(config: Settings | None = None) -> None:
|
||||
)
|
||||
if problems:
|
||||
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
|
||||
|
||||
|
||||
def validate_quorum_servers(endpoints: list, config: Settings | None = None) -> None:
|
||||
"""B-84 (ELC-19): refuse to serve with fewer than MIN_QUORUM_FALLBACK_SERVERS
|
||||
fallback servers (`endpoints` is the deduplicated rotation: primary first, then the
|
||||
fallbacks), unless ALLOW_NO_QUORUM=true. The old behaviour — an empty
|
||||
ELECTRUM_FALLBACK_SERVERS, the .env.example default — silently made every
|
||||
corroboration pass, i.e. one server decided the draw, the credits and the spends.
|
||||
|
||||
Called from the lifespan, after parse_endpoints, rather than from
|
||||
validate_runtime_secrets: it needs the parsed list, and a Settings validator would
|
||||
couple every import (tests included) to a populated .env.
|
||||
"""
|
||||
config = config or settings
|
||||
fallbacks = len(endpoints) - 1
|
||||
if fallbacks >= MIN_QUORUM_FALLBACK_SERVERS:
|
||||
return
|
||||
if config.allow_no_quorum:
|
||||
logger.warning(
|
||||
"ALLOW_NO_QUORUM=true: only %s fallback Electrum server(s) configured (%s needed) — "
|
||||
"corroboration is reduced to the weaker rule. Development only, never in production.",
|
||||
fallbacks,
|
||||
MIN_QUORUM_FALLBACK_SERVERS,
|
||||
)
|
||||
return
|
||||
raise ConfigError(
|
||||
f"ELECTRUM_FALLBACK_SERVERS lists {fallbacks} usable fallback server(s) besides the primary, "
|
||||
f"but at least {MIN_QUORUM_FALLBACK_SERVERS} are required so every corroboration has two independent "
|
||||
"answers (set ALLOW_NO_QUORUM=true to override, development only)"
|
||||
)
|
||||
+58
-5
@@ -1,16 +1,22 @@
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# How long a writer waits for a lock held by another writer before SQLite raises
|
||||
# "database is locked" (B-39). A few seconds is enough to ride out this app's own
|
||||
# five concurrent background tasks (scheduler, confirmation poller, RBF bumper,
|
||||
# two reconcilers) plus HTTP handlers briefly overlapping a write.
|
||||
# six concurrent background tasks (Electrum listener, scheduler, confirmation
|
||||
# poller, RBF bumper, two reconcilers) plus HTTP handlers briefly overlapping a
|
||||
# write.
|
||||
_SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
# Execution option read by the `begin` event below: which BEGIN a connection's next
|
||||
# transaction opens with. Set through `begin_immediate`, never by hand.
|
||||
_BEGIN_MODE_OPTION = "sqlite_begin_mode"
|
||||
|
||||
|
||||
def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
|
||||
"""Without WAL, SQLite's default (rollback-journal) mode lets a writer block
|
||||
every reader for the duration of its transaction, and a second writer arriving
|
||||
@@ -20,22 +26,69 @@ def _register_sqlite_pragmas(target_engine: AsyncEngine) -> None:
|
||||
busy_timeout gives a second writer a real window to wait for the first
|
||||
instead of an instant `OperationalError`.
|
||||
|
||||
B-88: two more things, both about the code's transactions being what it
|
||||
assumes. `synchronous=FULL`, because with WAL `NORMAL` can lose the last
|
||||
commits on power loss — a registration, and with it a derivation index that
|
||||
would then be handed to the next user. And real transaction control: pysqlite's
|
||||
legacy mode emits `BEGIN` only at the first INSERT/UPDATE/DELETE, so every read
|
||||
before it ran outside any transaction and the lock wait surfaced as `database is
|
||||
locked` at an arbitrary later statement. `isolation_level = None` turns the
|
||||
driver's implicit BEGIN off and the `begin` event emits it ourselves — a plain
|
||||
`BEGIN` by default, `BEGIN IMMEDIATE` for a transaction opened through
|
||||
`begin_immediate`, which takes the write lock *before* the first read so that
|
||||
what a money-moving transaction reads is what it then writes on top of.
|
||||
|
||||
No-op for any dialect other than sqlite (e.g. a future PostgreSQL
|
||||
DATABASE_URL), which neither needs nor understands these pragmas.
|
||||
DATABASE_URL), which neither needs nor understands any of this.
|
||||
"""
|
||||
if target_engine.dialect.name != "sqlite":
|
||||
return
|
||||
|
||||
@event.listens_for(target_engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None:
|
||||
# Before the pragmas: journal_mode cannot change inside a transaction, and in
|
||||
# legacy mode the driver may have one open.
|
||||
dbapi_connection.isolation_level = None
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA synchronous=FULL") # B-88
|
||||
cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
@event.listens_for(target_engine.sync_engine, "begin")
|
||||
def _emit_begin(conn) -> None: # B-88
|
||||
mode = conn.get_execution_options().get(_BEGIN_MODE_OPTION, "DEFERRED")
|
||||
conn.exec_driver_sql("BEGIN IMMEDIATE" if mode == "IMMEDIATE" else "BEGIN")
|
||||
|
||||
|
||||
async def begin_immediate(session: AsyncSession) -> None:
|
||||
"""B-88: start the session's next transaction with `BEGIN IMMEDIATE`, i.e. take
|
||||
SQLite's write lock now, before the first read.
|
||||
|
||||
Every transaction that moves money or fixes its state calls this first thing, so
|
||||
that what it reads (balance, free UTXOs, participants, the round's status) cannot
|
||||
change under it before its own writes, and so that the wait for the lock happens
|
||||
here — at the very start, before anything has been broadcast — instead of at the
|
||||
first write. Raises `sqlalchemy.exc.OperationalError` ("database is locked") when
|
||||
the lock is not granted within `busy_timeout`; the caller decides whether that
|
||||
means "refuse" (before a broadcast) or "leave it to the reconciler" (after).
|
||||
|
||||
A transaction the session already has open is committed first — a lock cannot be
|
||||
upgraded in place, and the callers open the transaction with nothing but reads
|
||||
behind them (typically the authentication lookup). Pending, unflushed changes
|
||||
make that unsafe to do silently, so they are an error.
|
||||
|
||||
Harmless on a session whose engine did not go through `_register_sqlite_pragmas`
|
||||
(the option is simply never read) and on a non-sqlite dialect.
|
||||
"""
|
||||
if session.new or session.dirty or session.deleted:
|
||||
raise RuntimeError("begin_immediate called with unflushed changes on the session")
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
await session.connection(execution_options={_BEGIN_MODE_OPTION: "IMMEDIATE"})
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url)
|
||||
_register_sqlite_pragmas(engine)
|
||||
|
||||
+90
-2
@@ -13,6 +13,16 @@ def utcnow() -> datetime:
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
# B-57: usernames are compared case-insensitively, and that has to be the
|
||||
# database's job, not a convention the query layer remembers. "Bob" and "bob"
|
||||
# used to be two accounts sharing one rate-limit bucket (each locking the other
|
||||
# out) and, worse on a custodial system, a ready-made impersonation vector.
|
||||
# A functional unique index rather than a normalized column: the name stays
|
||||
# stored exactly as the user typed it, which is what /admin and the audit log
|
||||
# display. The username pattern (auth/routes.py) is ASCII-only, so lower() is
|
||||
# the whole of the normalization — no Unicode casefolding subtleties apply.
|
||||
__table_args__ = (Index("ix_users_username_lower", text("lower(username)"), unique=True),)
|
||||
|
||||
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))
|
||||
@@ -72,6 +82,22 @@ class Round(Base):
|
||||
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)
|
||||
# B-61: the round's own timing, snapshotted from RoundConfig when it opens.
|
||||
# Read live from the config, a mid-round edit applied retroactively: lowering
|
||||
# round_duration_seconds from 600 to 60 while a round was 300s in closed it
|
||||
# instantly, and raising it moved the closes_at every client was already
|
||||
# counting down to. Same class of bug B-11 fixed for the advertised jackpot.
|
||||
# The config row is now what the *next* round opens with; these are what this
|
||||
# round runs by. cooldown_seconds is read off the round that just closed, so
|
||||
# the gap it announced is the gap that's honoured.
|
||||
duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600")
|
||||
cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30")
|
||||
# B-80 (DAT-60, RND-35): this round's own pool address, m/84'/746'/0'/2/<id>. The id
|
||||
# only exists once the row is flushed, so the row is inserted with "" and the real
|
||||
# address is written in the same transaction (rounds/service.py:open_new_round_if_needed)
|
||||
# — "" is never visible outside it, and every consumer refuses it. Rounds opened before
|
||||
# B-80 hold the reserve address (m/84'/746'/0'/1/0), their old shared pool.
|
||||
pool_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
|
||||
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
|
||||
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
|
||||
# (draw_waiting_since) measure how long a round has been waiting on a block,
|
||||
@@ -85,20 +111,48 @@ class Round(Base):
|
||||
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)
|
||||
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
|
||||
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
|
||||
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
|
||||
# `server_seed` stays NULL until the reveal, in the transaction that stores the winner (or
|
||||
# at the close of a round without participants), so a copy of the database alone never
|
||||
# tells who will win. `participants_hash`, `draw_height` and each participant's
|
||||
# `draw_position` are written when the round enters "drawing" and never change — what a
|
||||
# resumed draw (B-81) reads instead of recomputing. `draw_confirmations` is the number of
|
||||
# blocks the chain must have from `draw_height` before its hash is read, copied from the
|
||||
# config at open.
|
||||
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
||||
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
participants_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
draw_height: Mapped[int | None] = mapped_column(default=None)
|
||||
|
||||
|
||||
class RoundParticipant(Base):
|
||||
__tablename__ = "round_participants"
|
||||
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("round_id", "user_id"),
|
||||
# B-73 (DAT-66): one draw position per participant; NULLs (not yet in an ordered
|
||||
# list) do not collide.
|
||||
Index("ux_round_participants_round_draw_position", "round_id", "draw_position", unique=True),
|
||||
)
|
||||
|
||||
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.
|
||||
# B-73 (DAT-63): which output of the bet transaction pays the round's pool address —
|
||||
# together with bet_txid the outpoint the draw list publishes and the payout spends.
|
||||
bet_vout: Mapped[int] = mapped_column(default=0, server_default="0")
|
||||
# Informational only since B-73 (DAT-25): the draw order is by bet_txid, which anyone
|
||||
# can check, not by this server-side timestamp.
|
||||
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
# B-73 (DAT-66): 0-based position in the ordered draw list, written once on entering
|
||||
# "drawing" for every confirmed participant.
|
||||
draw_position: Mapped[int | None] = mapped_column(default=None)
|
||||
status: Mapped[str] = mapped_column(String(16), default="broadcast")
|
||||
|
||||
|
||||
@@ -123,12 +177,22 @@ class RoundConfig(Base):
|
||||
# NOT gate the actual draw, which still waits for a real confirmed block for
|
||||
# its entropy (rounds/scheduler.py) — that can take longer than this value.
|
||||
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
|
||||
# B-73 (DAT-62): blocks the chain must have from a round's draw_height before the hash
|
||||
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
|
||||
# applies from the next round.
|
||||
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
|
||||
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
|
||||
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
||||
# Maintenance switch: when true, the round currently in progress still runs to
|
||||
# completion (closes, draws, pays out the winner) but no new round is opened
|
||||
# afterwards — see rounds/service.py:open_new_round_if_needed.
|
||||
paused: Mapped[bool] = mapped_column(default=False)
|
||||
# B-78 (DAT-69): why the lottery is paused — NULL exactly when `paused` is false.
|
||||
# "operator" (POST /admin/pause) or an automatic reason the system wrote itself
|
||||
# ("resurrected_tx" so far; "reorg" and "payout_blocked" belong to B-85 and B-82).
|
||||
# An automatic reason is never overwritten by an operator pause, so the cause that
|
||||
# needs a look is not lost; POST /admin/resume clears whichever it is.
|
||||
paused_reason: Mapped[str | None] = mapped_column(String(16), default=None)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
@@ -171,9 +235,33 @@ class PendingTransaction(Base):
|
||||
attempt_count: Mapped[int] = mapped_column(default=1)
|
||||
# Why the reconciler gave up on this tx — operator-facing, only set on "failed".
|
||||
failure_reason: Mapped[str | None] = mapped_column(String(128), default=None)
|
||||
# B-78 (DAT-72): when the row went to "failed". A failed row stays watched for
|
||||
# FAILED_WATCH_DAYS from here: a transaction given up for dead can still confirm.
|
||||
failed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class PendingTransactionTxid(Base):
|
||||
"""Every version a PendingTransaction has ever had (B-77, DAT-70, DAT-71): the first
|
||||
one, each RBF replacement and each candidate written before a bump is broadcast.
|
||||
|
||||
The network may confirm *any* of them — the original while a bump was in flight, a
|
||||
replacement the process crashed before recording — so the poller and the reconciler
|
||||
look for every txid of a row, not only `current_txid`. Rows are never modified or
|
||||
deleted (except with the `bet` row `_release_failed_bet` removes); `txid` is unique
|
||||
over the whole table, so a txid belongs to exactly one platform transaction.
|
||||
`raw_hex` is empty only for a `replaced_by_txid` reconstructed by the migration,
|
||||
whose raw transaction is no longer available."""
|
||||
|
||||
__tablename__ = "pending_transaction_txids"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
pending_transaction_id: Mapped[int] = mapped_column(ForeignKey("pending_transactions.id"), index=True)
|
||||
txid: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
raw_hex: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
|
||||
|
||||
class Withdrawal(Base):
|
||||
__tablename__ = "withdrawals"
|
||||
|
||||
|
||||
+82
-1
@@ -4,13 +4,43 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import UtxoEvent
|
||||
from app.rounds.events import broadcaster
|
||||
from app.wallet.balance import recompute_balance
|
||||
from app.wallet.psbt_builder import DUST_LIMIT_SATS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
||||
"""The subset of `entries` that would actually credit something: confirmed
|
||||
(height > 0, per the Electrum convention where <= 0 means mempool) and not
|
||||
already recorded, and not dust (B-75: below DUST_LIMIT_SATS nothing is credited).
|
||||
|
||||
Split out from credit_confirmed_utxos so the caller
|
||||
(electrum/listener.py:refresh_user) can corroborate each *new* outpoint
|
||||
against the other configured servers before any of it is written (B-59) —
|
||||
the mirror image of what B-29 already required before a balance may go
|
||||
*down*. Only new ones: corroborating outpoints already credited would open a
|
||||
connection to every other server on every refresh, for an answer that can no
|
||||
longer change what we hold.
|
||||
"""
|
||||
existing_keys = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
await session.execute(select(UtxoEvent.txid, UtxoEvent.vout).where(UtxoEvent.user_id == user_id))
|
||||
).all()
|
||||
}
|
||||
return [
|
||||
entry
|
||||
for entry in entries
|
||||
if entry["height"] > 0
|
||||
and entry["value"] >= DUST_LIMIT_SATS # B-75 (DEP-01)
|
||||
and (entry["tx_hash"], entry["tx_pos"]) not in existing_keys
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
@@ -19,7 +49,12 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
|
||||
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.
|
||||
|
||||
B-88: opens with BEGIN IMMEDIATE, so the "already recorded?" read and the inserts
|
||||
it guards are one transaction — the unique (txid, vout) constraint stays the
|
||||
backstop, not the mechanism.
|
||||
"""
|
||||
await begin_immediate(session)
|
||||
existing_keys = {
|
||||
(txid, vout)
|
||||
for txid, vout in (
|
||||
@@ -31,6 +66,11 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
|
||||
for entry in entries:
|
||||
if entry["height"] <= 0:
|
||||
continue
|
||||
if entry["value"] < DUST_LIMIT_SATS:
|
||||
# B-75 (DEP-01): anyone can send dust to a public address; crediting it would
|
||||
# make every later refresh of this user's address do more work, for coins
|
||||
# that cost more to spend than they are worth.
|
||||
continue
|
||||
key = (entry["tx_hash"], entry["tx_pos"])
|
||||
if key in existing_keys:
|
||||
continue
|
||||
@@ -63,15 +103,55 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l
|
||||
_EXTERNAL_SPEND_SENTINEL = "external-spend"
|
||||
|
||||
|
||||
async def find_reinstate_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
|
||||
"""Outpoints flagged as spent outside the platform that this refresh's `listunspent`
|
||||
lists again, confirmed and for the amount we recorded — the *candidates* for
|
||||
reinstatement (B-83, DEP-02, DEP-08). Read-only: like a new credit (B-59) and a
|
||||
candidate external spend (B-29), a reinstatement moves a balance, so the caller
|
||||
(electrum/listener.py:refresh_user) must have the other servers corroborate each
|
||||
candidate before reinstate_reappeared_utxos writes it. An entry whose amount differs
|
||||
from our record is not a candidate: it is not the coin we wrote off."""
|
||||
flagged = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL)
|
||||
)
|
||||
).all()
|
||||
amounts = {(row.txid, row.vout): row.amount_sats for row in flagged}
|
||||
return [
|
||||
entry
|
||||
for entry in entries
|
||||
if amounts.get((entry["tx_hash"], entry["tx_pos"])) == entry.get("value") and (entry.get("height") or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
async def reinstate_reappeared_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
|
||||
"""The reverse of a mark applied by find_utxos_missing_from/
|
||||
mark_utxos_spent_externally (B-29): if an outpoint we'd previously flagged as
|
||||
spent outside the platform reappears as unspent in a later listunspent, undo
|
||||
the mark instead of leaving it permanent no matter what the chain says
|
||||
afterwards. Cheap and purely DB-side — always safe to run on every refresh.
|
||||
afterwards.
|
||||
|
||||
Persistence only, for entries the caller has **already corroborated** against the
|
||||
other servers (B-83, ELC-25) — it used to run on the active server's word alone, so a
|
||||
hostile one could put a spent coin back into a balance. Re-checks, under the write
|
||||
lock, that each row still carries the mark before clearing it (DEP-08).
|
||||
"""
|
||||
current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries}
|
||||
|
||||
# B-88: the lock is only worth taking when there is something to write, and this
|
||||
# runs on every refresh — so look first (plain columns), then take it and read the
|
||||
# rows again under it.
|
||||
flagged = (
|
||||
await session.execute(
|
||||
select(UtxoEvent.txid, UtxoEvent.vout).where(
|
||||
UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL
|
||||
)
|
||||
)
|
||||
).all()
|
||||
if not any((txid, vout) in current_keys for txid, vout in flagged):
|
||||
return 0
|
||||
await begin_immediate(session)
|
||||
|
||||
marked_rows = (
|
||||
await session.scalars(
|
||||
select(UtxoEvent).where(
|
||||
@@ -152,6 +232,7 @@ async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_
|
||||
may have resolved it (a legitimate platform spend, or a prior refresh) between
|
||||
when the caller read the candidate list and finished corroborating it.
|
||||
"""
|
||||
await begin_immediate(session) # B-88: that re-check and the mark are one transaction
|
||||
marked = 0
|
||||
for utxo_id in utxo_ids:
|
||||
row = await session.get(UtxoEvent, utxo_id)
|
||||
|
||||
+186
-24
@@ -1,57 +1,104 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import ssl
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElectrumEndpoint(NamedTuple):
|
||||
"""One server to connect to. The listener rotates over a list of these so a
|
||||
single unreachable or misbehaving server doesn't take the platform down —
|
||||
every consumer of PLM chain data goes through this one connection."""
|
||||
every consumer of PLM chain data goes through this one connection.
|
||||
|
||||
`pin` (B-84, ELC-03) is the SHA-256 of the server's DER certificate, in lower-case
|
||||
hex; when set, a connection presenting any other certificate fails like an
|
||||
unreachable server. Only meaningful with TLS."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
use_ssl: bool = True
|
||||
pin: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}"
|
||||
return f"{self.host}:{self.port}{'' if self.use_ssl else ' (plaintext)'}{' (pinned)' if self.pin else ''}"
|
||||
|
||||
|
||||
_PIN_PATTERN = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
def normalize_pin(raw: str, entry: str) -> str:
|
||||
"""A certificate pin as lower-case hex, or ValueError: 64 hexadecimal digits."""
|
||||
pin = raw.strip().lower()
|
||||
if not _PIN_PATTERN.fullmatch(pin):
|
||||
raise ValueError(f"invalid certificate pin in Electrum server {entry!r}: expected 64 hex digits (sha256)")
|
||||
return pin
|
||||
|
||||
|
||||
def parse_endpoints(
|
||||
primary_host: str, primary_port: int, primary_use_ssl: bool, fallback_spec: str
|
||||
primary_host: str,
|
||||
primary_port: int,
|
||||
primary_use_ssl: bool,
|
||||
fallback_spec: str,
|
||||
primary_pin: str = "",
|
||||
) -> list[ElectrumEndpoint]:
|
||||
"""Build the connection rotation: the primary server first, then whatever
|
||||
ELECTRUM_FALLBACK_SERVERS lists.
|
||||
|
||||
`fallback_spec` is comma-separated, each entry `host:port` (TLS, the normal
|
||||
case) or `host:port:notls`. Malformed entries raise ValueError rather than
|
||||
being skipped: a typo in a fallback server is something to fix at startup,
|
||||
not to discover during an outage, when the fallback is what's needed.
|
||||
case), `host:port:notls`, `host:port:pin=<sha256>` (TLS with a pinned certificate,
|
||||
B-84) or `host:port:notls`/`ssl`/`tls` followed by `:pin=<sha256>`. A pin on a
|
||||
plaintext entry is refused: there is no certificate to pin. `primary_pin` is the
|
||||
same pin for the primary (ELECTRUM_CERT_PIN). Malformed entries raise ValueError
|
||||
rather than being skipped: a typo in a fallback server is something to fix at
|
||||
startup, not to discover during an outage, when the fallback is what's needed.
|
||||
Duplicates are dropped, keeping first position.
|
||||
"""
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl)]
|
||||
syntax = "expected host:port[:notls][:pin=<sha256>]"
|
||||
pin = normalize_pin(primary_pin, "ELECTRUM_CERT_PIN") if primary_pin.strip() else None
|
||||
if pin and not primary_use_ssl:
|
||||
raise ValueError("ELECTRUM_CERT_PIN needs TLS: ELECTRUM_USE_SSL is off")
|
||||
endpoints = [ElectrumEndpoint(primary_host, primary_port, primary_use_ssl, pin)]
|
||||
for raw in fallback_spec.split(","):
|
||||
entry = raw.strip()
|
||||
if not entry:
|
||||
continue
|
||||
parts = entry.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
if not 2 <= len(parts) <= 4:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
|
||||
host, port = parts[0].strip(), parts[1].strip()
|
||||
if not host or not port.isdigit():
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: expected host:port[:notls]")
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: {syntax}")
|
||||
use_ssl = True
|
||||
if len(parts) == 3:
|
||||
flag = parts[2].strip().lower()
|
||||
if flag not in ("ssl", "tls", "notls", "plain"):
|
||||
pin = None
|
||||
mode_seen = False
|
||||
for extra in parts[2:]:
|
||||
extra = extra.strip()
|
||||
if extra.lower().startswith("pin="):
|
||||
if pin is not None:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: more than one pin")
|
||||
pin = normalize_pin(extra[4:], entry)
|
||||
continue
|
||||
flag = extra.lower()
|
||||
if mode_seen or flag not in ("ssl", "tls", "notls", "plain"):
|
||||
raise ValueError(f"invalid TLS flag {flag!r} in Electrum server {entry!r}")
|
||||
mode_seen = True
|
||||
use_ssl = flag in ("ssl", "tls")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl))
|
||||
if pin and not use_ssl:
|
||||
raise ValueError(f"invalid Electrum server {entry!r}: a pin needs TLS")
|
||||
endpoints.append(ElectrumEndpoint(host, int(port), use_ssl, pin))
|
||||
|
||||
deduped: list[ElectrumEndpoint] = []
|
||||
seen: set[tuple] = set()
|
||||
for endpoint in endpoints:
|
||||
if endpoint not in deduped:
|
||||
key = (endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(endpoint)
|
||||
return deduped
|
||||
|
||||
@@ -64,11 +111,37 @@ def parse_endpoints(
|
||||
# and the confirmation poller could stop polling permanently.
|
||||
_REQUEST_TIMEOUT_SECONDS = 15
|
||||
|
||||
# Longest reply line the client accepts (B-75, ELC-20). asyncio's default StreamReader
|
||||
# limit is 64 KiB, and a longer line used to raise out of the read loop and close the
|
||||
# only connection: the pool address's get_history passed that after ~800 transactions
|
||||
# (and a few hundred dust outputs sent to any public address do it on purpose), after
|
||||
# which every confirmation poll dropped the connection and payouts never confirmed.
|
||||
_MAX_LINE_BYTES = 16 * 1024 * 1024
|
||||
_READ_CHUNK_BYTES = 64 * 1024
|
||||
# How much of each end of a discarded line is kept to find the reply's `id` in, so the
|
||||
# request it answered can fail at once instead of waiting out the 15s timeout.
|
||||
_ID_SNIFF_BYTES = 256
|
||||
_ID_PATTERN = re.compile(rb'"id"\s*:\s*(\d+)')
|
||||
|
||||
|
||||
class ElectrumError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ElectrumRpcError(ElectrumError):
|
||||
"""The server *answered* with a JSON-RPC `error` object (B-76, TX-04). It is the
|
||||
only failure that says anything about the request itself: a timeout, a dropped
|
||||
connection or a write error (plain ElectrumError) leaves it unknown whether the
|
||||
server ever acted on it. `args[0]` is the error object as the server sent it."""
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
detail = self.args[0] if self.args else ""
|
||||
if isinstance(detail, dict):
|
||||
return str(detail.get("message", detail))
|
||||
return str(detail)
|
||||
|
||||
|
||||
class ElectrumClient:
|
||||
"""Minimal asyncio Electrum protocol client: line-delimited JSON-RPC over TLS.
|
||||
|
||||
@@ -83,10 +156,11 @@ class ElectrumClient:
|
||||
notification queues nobody will ever fill again.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True):
|
||||
def __init__(self, host: str, port: int, use_ssl: bool = True, pin: str | None = None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.use_ssl = use_ssl
|
||||
self.pin = pin.lower() if pin else None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._id_counter = itertools.count(1)
|
||||
@@ -98,16 +172,36 @@ class ElectrumClient:
|
||||
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.
|
||||
# encryption and don't verify the certificate chain/hostname. What can be
|
||||
# asked instead is that the certificate be *the* one the operator pinned
|
||||
# (B-84, ELC-03): without a pin, whoever sits on the path to a server takes
|
||||
# its place, and only the quorum stands between that and the draw.
|
||||
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._reader, self._writer = await asyncio.open_connection(
|
||||
self.host, self.port, ssl=ssl_context, limit=_MAX_LINE_BYTES
|
||||
)
|
||||
if self.pin:
|
||||
try:
|
||||
self._check_pin()
|
||||
except ElectrumError:
|
||||
self._writer.close()
|
||||
raise
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
await self.request("server.version", ["plm-lottery", "1.4"])
|
||||
|
||||
def _check_pin(self) -> None:
|
||||
"""The server's DER certificate must hash to the pinned SHA-256 (B-84, ELC-03)."""
|
||||
ssl_object = self._writer.get_extra_info("ssl_object") if self._writer is not None else None
|
||||
der = ssl_object.getpeercert(binary_form=True) if ssl_object is not None else None
|
||||
if not der:
|
||||
raise ElectrumError(f"{self.host}:{self.port} presented no certificate to check against the pin")
|
||||
if hashlib.sha256(der).hexdigest() != self.pin:
|
||||
raise ElectrumError(f"{self.host}:{self.port} certificate does not match the configured pin")
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed.set()
|
||||
if self._read_task is not None:
|
||||
@@ -186,17 +280,58 @@ class ElectrumClient:
|
||||
async def get_transaction(self, txid: str, verbose: bool = False) -> object:
|
||||
return await self.request("blockchain.transaction.get", [txid, verbose])
|
||||
|
||||
async def get_merkle(self, txid: str, height: int) -> dict:
|
||||
"""The merkle branch proving `txid` is in the block at `height` (B-83, ELC-26)."""
|
||||
return await self.request("blockchain.transaction.get_merkle", [txid, height])
|
||||
|
||||
async def get_block_header(self, height: int) -> str:
|
||||
return await self.request("blockchain.block.header", [height])
|
||||
|
||||
async def get_block_headers(self, start_height: int, count: int) -> dict:
|
||||
"""`count` consecutive headers from `start_height` (B-74, ELC-12), as the server's
|
||||
{"count", "hex", "max"}: `hex` is the 80-byte headers concatenated, and the server
|
||||
may return fewer than asked (never more than its `max`)."""
|
||||
return await self.request("blockchain.block.headers", [start_height, count])
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
"""Frame the stream into lines by hand rather than with StreamReader.readline:
|
||||
readline raises on a line over the limit, and that exception used to end this
|
||||
loop and with it the connection. An oversized line is now dropped up to its
|
||||
newline (B-75, ELC-20) and the connection stays up."""
|
||||
assert self._reader is not None
|
||||
buffer = bytearray()
|
||||
discarding = False
|
||||
head = b""
|
||||
tail = b""
|
||||
try:
|
||||
while True:
|
||||
line = await self._reader.readline()
|
||||
if not line:
|
||||
chunk = await self._reader.read(_READ_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
message = json.loads(line)
|
||||
self._dispatch(message)
|
||||
while chunk:
|
||||
newline = chunk.find(b"\n")
|
||||
piece = chunk if newline < 0 else chunk[:newline]
|
||||
chunk = b"" if newline < 0 else chunk[newline + 1 :]
|
||||
if discarding:
|
||||
tail = (tail + piece)[-_ID_SNIFF_BYTES:]
|
||||
else:
|
||||
buffer += piece
|
||||
if len(buffer) > _MAX_LINE_BYTES:
|
||||
discarding = True
|
||||
head = bytes(buffer[:_ID_SNIFF_BYTES])
|
||||
tail = bytes(buffer[-_ID_SNIFF_BYTES:])
|
||||
buffer.clear()
|
||||
if newline >= 0:
|
||||
if discarding:
|
||||
self._fail_unreadable_reply(head + b" " + tail, "reply exceeds the line limit")
|
||||
discarding = False
|
||||
else:
|
||||
self._handle_line(bytes(buffer))
|
||||
buffer.clear()
|
||||
if buffer and not discarding:
|
||||
self._handle_line(bytes(buffer)) # a final line the server never terminated
|
||||
finally:
|
||||
# Whatever ended this loop — clean EOF, protocol error, TLS failure — the
|
||||
# Whatever ended this loop — clean EOF, TLS failure — the
|
||||
# connection is unusable from here on. Setting this is what makes the
|
||||
# death observable to wait_closed(), and so to the listener's reconnect
|
||||
# logic; without it the listener waited on notification queues nobody
|
||||
@@ -208,6 +343,33 @@ class ElectrumClient:
|
||||
future.set_exception(error)
|
||||
self._pending.clear()
|
||||
|
||||
def _handle_line(self, line: bytes) -> None:
|
||||
if not line.strip():
|
||||
return
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except ValueError:
|
||||
# A reply that is not JSON is a problem of that one request, not of the
|
||||
# connection (B-75).
|
||||
self._fail_unreadable_reply(line[:_ID_SNIFF_BYTES] + b" " + line[-_ID_SNIFF_BYTES:], "reply is not valid JSON")
|
||||
return
|
||||
if not isinstance(message, dict):
|
||||
logger.warning("ignoring an Electrum message that is not a JSON object")
|
||||
return
|
||||
self._dispatch(message)
|
||||
|
||||
def _fail_unreadable_reply(self, sample: bytes, reason: str) -> None:
|
||||
"""Fail the request an unusable reply belongs to, when its `id` can be read
|
||||
from `sample` (the first and last bytes of the line); otherwise that request
|
||||
fails by its own timeout (ELC-04)."""
|
||||
logger.warning("dropping an unusable Electrum reply: %s", reason)
|
||||
for match in _ID_PATTERN.finditer(sample):
|
||||
future = self._pending.pop(int(match.group(1)), None)
|
||||
if future is not None:
|
||||
if not future.done():
|
||||
future.set_exception(ElectrumError(reason))
|
||||
return
|
||||
|
||||
def _dispatch(self, message: dict) -> None:
|
||||
message_id = message.get("id")
|
||||
if message_id is not None and message_id in self._pending:
|
||||
@@ -215,7 +377,7 @@ class ElectrumClient:
|
||||
if future.done():
|
||||
return
|
||||
if message.get("error"):
|
||||
future.set_exception(ElectrumError(message["error"]))
|
||||
future.set_exception(ElectrumRpcError(message["error"]))
|
||||
else:
|
||||
future.set_result(message.get("result"))
|
||||
elif "method" in message:
|
||||
|
||||
+666
-71
@@ -1,18 +1,28 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import User
|
||||
from app.deposits.service import (
|
||||
credit_confirmed_utxos,
|
||||
find_new_credit_candidates,
|
||||
find_reinstate_candidates,
|
||||
find_utxos_missing_from,
|
||||
mark_utxos_spent_externally,
|
||||
reinstate_reappeared_utxos,
|
||||
)
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint
|
||||
from app.config import MIN_QUORUM_RESPONSES
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, ElectrumRpcError
|
||||
from app.electrum.merkle import proof_matches_header
|
||||
from app.electrum.quorum import ServerAnswers
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.draw import (
|
||||
HeaderValidationError,
|
||||
@@ -21,6 +31,7 @@ from app.rounds.draw import (
|
||||
header_prev_hash,
|
||||
)
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.pause import pause_automatically
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,6 +51,31 @@ _CORROBORATION_TIMEOUT_SECONDS = 10
|
||||
# of simultaneous in-flight requests against the one active connection.
|
||||
_RESUBSCRIBE_CONCURRENCY = 20
|
||||
|
||||
# How many of the latest accepted headers the listener keeps (B-74/B-85, ELC-22): what lets
|
||||
# it recognise a reorganisation and walk back to its fork point instead of mistaking it for
|
||||
# a hostile server. Also the deepest fork it will follow.
|
||||
_HEADER_WINDOW = 10
|
||||
|
||||
# B-86 (ELC-21): a connected server whose last accepted header is older than this is asked
|
||||
# about by comparing its tip with the other servers' at every ping. Three block times of
|
||||
# 120s: late enough that an ordinary slow stretch of the chain does not trigger the check.
|
||||
_STALL_SECONDS = 360
|
||||
|
||||
# The clock the stall check reads — a module attribute so a test can move time.
|
||||
_monotonic = time.monotonic
|
||||
|
||||
# A reorganisation at least this deep pauses the lottery (B-85, ELC-24): one block is
|
||||
# ordinary on any chain, two or more is worth a look before a new round opens.
|
||||
_REORG_PAUSE_DEPTH = 2
|
||||
|
||||
# `blockchain.block.headers` serves at most this many headers per request (ELC-12).
|
||||
_MAX_HEADERS_PER_REQUEST = 2016
|
||||
|
||||
|
||||
class ServerStalledError(Exception):
|
||||
"""The active server stopped following the chain while others report a higher tip
|
||||
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
|
||||
|
||||
|
||||
class ElectrumListener:
|
||||
"""Long-lived background task: keeps one Electrum connection open, subscribes
|
||||
@@ -62,10 +98,13 @@ class ElectrumListener:
|
||||
client_factory: Callable[[ElectrumEndpoint], ElectrumClient],
|
||||
session_factory: async_sessionmaker,
|
||||
endpoints: list[ElectrumEndpoint] | None = None,
|
||||
allow_no_quorum: bool = False,
|
||||
):
|
||||
self._client_factory = client_factory
|
||||
self._session_factory = session_factory
|
||||
self._endpoints = list(endpoints or [])
|
||||
# B-84 (ELC-19): ALLOW_NO_QUORUM, development only — see _corroborate_majority.
|
||||
self._allow_no_quorum = allow_no_quorum
|
||||
self._endpoint_index = 0
|
||||
self._scripthash_to_user: dict[str, int] = {}
|
||||
# Retains address_for_new_user's fire-and-forget subscribe task so it
|
||||
@@ -75,6 +114,11 @@ class ElectrumListener:
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self.tip_height: int = 0
|
||||
self.tip_header_hex: str | None = None
|
||||
# B-74 (ELC-22): height -> header of the last _HEADER_WINDOW accepted headers, tip
|
||||
# included. Survives reconnections, not restarts.
|
||||
self._window: dict[int, str] = {}
|
||||
# B-86: when the last header was accepted (monotonic seconds), None until one is.
|
||||
self._last_header_at: float | None = None
|
||||
self.client: ElectrumClient | None = None
|
||||
|
||||
@property
|
||||
@@ -159,12 +203,31 @@ class ElectrumListener:
|
||||
reset its backoff), False if it never got that far."""
|
||||
client = self._client_factory(endpoint)
|
||||
await client.connect()
|
||||
self.client = client
|
||||
logger.info("Electrum connected to %s", endpoint)
|
||||
|
||||
try:
|
||||
header = await client.subscribe_headers()
|
||||
self._apply_header(header)
|
||||
await self._apply_header(header, client)
|
||||
if self.tip_header_hex is None:
|
||||
# B-64: the header was unusable (no hex) and we have never had a tip,
|
||||
# so publishing this client would hand every consumer a connection
|
||||
# whose chain position is unknown — B-63 all over again. A server at
|
||||
# or behind a tip we already know is fine and doesn't come through
|
||||
# here: the point is only that *some* tip is established.
|
||||
raise HeaderValidationError(
|
||||
f"{endpoint} announced an unusable initial header ({header!r}) and no tip is known"
|
||||
)
|
||||
# B-63: published only now, never before the first header has been
|
||||
# applied. `self.client is not None` is what every consumer treats as
|
||||
# "the chain is reachable" — including RoundScheduler._tick, which then
|
||||
# reads tip_height as the baseline a draw must find a *later* block than.
|
||||
# Assigning it before this round-trip left a window where the connection
|
||||
# looked alive while tip_height was still 0, so a round closing inside it
|
||||
# recorded a baseline of 0 and the very first header we learned — the
|
||||
# current tip, a block mined *before* the round closed, with a hash
|
||||
# already public while bets were still open — satisfied
|
||||
# `tip_height > tip_at_close` and seeded the draw.
|
||||
self.client = client
|
||||
|
||||
headers_queue = client.notifications("blockchain.headers.subscribe")
|
||||
scripthash_queue = client.notifications("blockchain.scripthash.subscribe")
|
||||
@@ -173,7 +236,7 @@ class ElectrumListener:
|
||||
# keepalive are what make the drop observable. Whichever finishes first
|
||||
# ends the session and sends run() around to the next server.
|
||||
tasks = [
|
||||
asyncio.create_task(self._consume_headers(headers_queue)),
|
||||
asyncio.create_task(self._consume_headers(headers_queue, client)),
|
||||
asyncio.create_task(self._consume_scripthash(scripthash_queue)),
|
||||
asyncio.create_task(self._keepalive(client)),
|
||||
asyncio.create_task(client.wait_closed()),
|
||||
@@ -219,6 +282,29 @@ class ElectrumListener:
|
||||
while True:
|
||||
await asyncio.sleep(_PING_INTERVAL_SECONDS)
|
||||
await client.ping() # raises (and so ends the session) on timeout or a dead socket
|
||||
await self._check_not_stalled()
|
||||
|
||||
async def _check_not_stalled(self) -> None:
|
||||
"""B-86 (ELC-21): `server.ping` keeps succeeding on a server that has stopped
|
||||
following the chain, so liveness alone never replaced one. When the last accepted
|
||||
header is older than _STALL_SECONDS, ask every *other* server for its tip
|
||||
(`blockchain.headers.subscribe`, with ELC-14's timeouts): if any reports a higher one
|
||||
than we know, this session ends and the listener moves to the next server. If none
|
||||
does, the network simply produced no block and the session goes on."""
|
||||
if self._last_header_at is None or _monotonic() - self._last_header_at <= _STALL_SECONDS:
|
||||
return
|
||||
|
||||
async def _tip_height(client: ElectrumClient) -> int | None:
|
||||
header = await client.subscribe_headers()
|
||||
height = header.get("height") if isinstance(header, dict) else None
|
||||
return height if isinstance(height, int) else None
|
||||
|
||||
heights = [h for h in await self._ask_other_servers(_tip_height) if h is not None]
|
||||
if any(height > self.tip_height for height in heights):
|
||||
raise ServerStalledError(
|
||||
f"no header accepted for {_monotonic() - self._last_header_at:.0f}s while another server "
|
||||
f"reports tip {max(heights)} (ours: {self.tip_height})"
|
||||
)
|
||||
|
||||
async def _subscribe_all_users(self) -> None:
|
||||
"""B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at
|
||||
@@ -248,29 +334,47 @@ class ElectrumListener:
|
||||
await self.client.subscribe_scripthash(scripthash)
|
||||
await self.refresh_user(user_id, scripthash)
|
||||
|
||||
def _apply_header(self, header: dict) -> None:
|
||||
"""Record a new chain tip, refusing to move backwards.
|
||||
async def _apply_header(self, header: dict, client: ElectrumClient | None = None) -> None:
|
||||
"""Record a new chain tip after checking it (ELC-08 … ELC-13, ELC-22, ELC-23).
|
||||
|
||||
Full reorg handling is out of scope for v1 by explicit design decision, but
|
||||
the tip must never regress: `_wait_for_next_block` waits for
|
||||
`tip_height > tip_at_close`, so a lower height would silently add a block to
|
||||
the draw's wait. height and hex are applied together or not at all —
|
||||
applying a losing header's hex would leave tip_height and tip_header_hex
|
||||
describing different blocks, and that hex is the draw's entropy source.
|
||||
The tip never regresses, and `tip_height`/`tip_header_hex` always describe the
|
||||
same block: they are applied together or not at all, since that hex is where a
|
||||
draw's entropy comes from. A header that is ignored (no hex, below the tip, the
|
||||
tip itself again) changes nothing and ends nothing.
|
||||
|
||||
Two validation checks guard against a hostile or MITM'd server simply
|
||||
fabricating a header (B-28), since that header is the draw's sole source of
|
||||
entropy: it must satisfy the difficulty target it claims for itself, and —
|
||||
when it's a direct single-block advance from our own current tip, the only
|
||||
case we can check without a full header chain — it must chain from that
|
||||
tip's hash. Either failure raises HeaderValidationError rather than
|
||||
silently ignoring the header, which (via _consume_headers/_run_once) ends
|
||||
this session the same way a dropped connection would, so run() rotates to
|
||||
the next configured server instead of continuing to trust this one.
|
||||
Everything else must be proven, because a forged header steers a draw (B-28):
|
||||
it must meet the target it claims *and* that target must be no easier than
|
||||
PLM's powLimit (B-74, ELC-11), and every advance must be linked to the tip we
|
||||
hold (ELC-12) — a single block by its `prev_hash`, a jump of several by
|
||||
downloading the headers in between from `client` and checking each. A header that
|
||||
fails these raises HeaderValidationError, which (via _consume_headers/_run_once)
|
||||
ends this session like a dropped connection and rotates to the next server.
|
||||
|
||||
A header that merely does not *join* our tip is not hostile in itself: it is a
|
||||
reorganisation, and is followed back to its fork point inside the last
|
||||
_HEADER_WINDOW headers (B-74, ELC-23) instead of ending the session on every
|
||||
server until the next block arrives. `client` is the active connection the
|
||||
missing headers are fetched from.
|
||||
"""
|
||||
height = header["height"]
|
||||
header_hex = header.get("hex")
|
||||
if height < self.tip_height:
|
||||
|
||||
if not header_hex:
|
||||
# Nothing to validate and nothing to draw from — and applying the height
|
||||
# alone would break exactly the pairing this function exists to keep:
|
||||
# tip_height would describe a block tip_header_hex doesn't (and on a
|
||||
# session's first header, would publish a client whose tip is unknown,
|
||||
# which is B-63). Ignored rather than fatal: a server that only ever
|
||||
# pushed heights would freeze the draw — visibly, via B-36's
|
||||
# draw_stalled — instead of costing us the one connection that also
|
||||
# credits deposits and broadcasts transactions. _run_once separately
|
||||
# refuses to publish a client while the tip is still unknown.
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s: no header hex to validate or to draw from", height
|
||||
)
|
||||
return
|
||||
|
||||
if self.tip_header_hex is not None and height < self.tip_height:
|
||||
logger.warning(
|
||||
"ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)",
|
||||
height,
|
||||
@@ -278,22 +382,159 @@ class ElectrumListener:
|
||||
)
|
||||
return
|
||||
|
||||
if header_hex:
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not satisfy its own claimed difficulty target"
|
||||
)
|
||||
if (
|
||||
self.tip_header_hex
|
||||
and height == self.tip_height + 1
|
||||
and header_prev_hash(header_hex) != header_hex_to_block_hash(self.tip_header_hex)
|
||||
):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} does not chain from the current tip (height {self.tip_height})"
|
||||
)
|
||||
if self.tip_header_hex is not None and height == self.tip_height:
|
||||
if header_hex == self.tip_header_hex:
|
||||
return # the same block announced again
|
||||
# B-85 (ELC-09): another block at the height we hold — a reorg at the tip. It is
|
||||
# followed like any other fork (it must bring the work and the chain to show for
|
||||
# it), not ignored: ignoring it left us on an orphan for as long as the tip
|
||||
# stood, and a draw seeded from it would have been refused by the quorum.
|
||||
await self._follow_reorg(height, header_hex, client)
|
||||
return
|
||||
|
||||
self.tip_height = height
|
||||
self.tip_header_hex = header_hex
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError(
|
||||
f"header at height {height} is not a valid proof of work "
|
||||
"(its hash misses the target it claims, or the target is easier than the powLimit)"
|
||||
)
|
||||
|
||||
if self.tip_header_hex is None:
|
||||
await self._adopt_first_header(height, header_hex, client)
|
||||
return
|
||||
|
||||
tip_hash = header_hex_to_block_hash(self.tip_header_hex)
|
||||
if height == self.tip_height + 1:
|
||||
if header_prev_hash(header_hex) == tip_hash:
|
||||
self._extend_window([(height, header_hex)])
|
||||
return
|
||||
await self._follow_reorg(height, header_hex, client)
|
||||
return
|
||||
|
||||
# A jump of several blocks (a reconnect, a server that was ahead of us): link it to
|
||||
# the tip by fetching what lies in between — an unlinked jump used to be accepted
|
||||
# on the strength of its own difficulty alone.
|
||||
if client is None:
|
||||
raise HeaderValidationError(f"cannot link the header at height {height} to the tip: no connection to fetch from")
|
||||
between = await self._fetch_headers(client, self.tip_height + 1, height - self.tip_height - 1)
|
||||
chain = [*between, header_hex]
|
||||
self._check_chained(chain, header_prev_hash(chain[0])) # internal linkage and work only
|
||||
if header_prev_hash(chain[0]) != tip_hash:
|
||||
await self._follow_reorg(height, header_hex, client)
|
||||
return
|
||||
self._extend_window([(self.tip_height + 1 + i, h) for i, h in enumerate(chain)])
|
||||
|
||||
async def _fetch_headers(self, client: ElectrumClient, start: int, count: int) -> list[str]:
|
||||
"""`count` headers from `start` (blockchain.block.headers, at most
|
||||
_MAX_HEADERS_PER_REQUEST per request). A server that returns fewer than it must,
|
||||
or something that is not whole 80-byte headers, ends the session."""
|
||||
headers: list[str] = []
|
||||
while len(headers) < count:
|
||||
wanted = min(count - len(headers), _MAX_HEADERS_PER_REQUEST)
|
||||
reply = await client.get_block_headers(start + len(headers), wanted)
|
||||
got = reply.get("count") if isinstance(reply, dict) else None
|
||||
raw = reply.get("hex") if isinstance(reply, dict) else None
|
||||
if not isinstance(got, int) or not isinstance(raw, str) or not 0 < got <= wanted or len(raw) != got * 160:
|
||||
raise HeaderValidationError(
|
||||
f"the server returned an unusable reply for headers {start + len(headers)}..+{wanted}"
|
||||
)
|
||||
headers.extend(raw[i : i + 160] for i in range(0, len(raw), 160))
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _check_chained(headers: list[str], prev_hash: str) -> None:
|
||||
"""Every header carries valid work and each one builds on the one before it, the
|
||||
first on `prev_hash` (ELC-11, ELC-12). Raises HeaderValidationError."""
|
||||
for header_hex in headers:
|
||||
if not header_meets_its_own_target(header_hex):
|
||||
raise HeaderValidationError("a downloaded header is not a valid proof of work")
|
||||
if header_prev_hash(header_hex) != prev_hash:
|
||||
raise HeaderValidationError("the downloaded headers are not a connected chain")
|
||||
prev_hash = header_hex_to_block_hash(header_hex)
|
||||
|
||||
def _extend_window(self, entries: list[tuple[int, str]]) -> None:
|
||||
"""Make `entries` (consecutive heights, the last the new tip) part of the window,
|
||||
replacing anything at or above their first height, and move the tip with it."""
|
||||
first = entries[0][0]
|
||||
for stale in [h for h in self._window if h >= first]:
|
||||
del self._window[stale]
|
||||
self._window.update(entries)
|
||||
for old in sorted(self._window)[:-_HEADER_WINDOW]:
|
||||
del self._window[old]
|
||||
self.tip_height, self.tip_header_hex = entries[-1]
|
||||
self._last_header_at = _monotonic()
|
||||
|
||||
async def _adopt_first_header(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
|
||||
"""The process's first header has nothing to join, so it is taken on its work alone
|
||||
— and the (up to) 9 headers before it are downloaded and must chain into it,
|
||||
which is what the window of ELC-22 is made of."""
|
||||
entries: list[tuple[int, str]] = []
|
||||
if client is not None and height > 0:
|
||||
count = min(_HEADER_WINDOW - 1, height)
|
||||
before = await self._fetch_headers(client, height - count, count)
|
||||
first_prev = header_prev_hash(before[0])
|
||||
self._check_chained(before, first_prev)
|
||||
if header_prev_hash(header_hex) != header_hex_to_block_hash(before[-1]):
|
||||
raise HeaderValidationError(f"the header at height {height} does not chain from the headers before it")
|
||||
entries = [(height - count + i, h) for i, h in enumerate(before)]
|
||||
self._window.clear()
|
||||
self._extend_window([*entries, (height, header_hex)])
|
||||
|
||||
async def _follow_reorg(self, height: int, header_hex: str, client: ElectrumClient | None) -> None:
|
||||
"""The header at `height` does not build on our tip: find where the server's chain
|
||||
and ours part (the highest window height at which the server's hash is the same as
|
||||
ours) and, if that is inside the window, adopt the server's branch from there on
|
||||
(ELC-23). A fork older than the window, or a branch that is not a connected chain
|
||||
of valid work ending at the announced header, raises HeaderValidationError."""
|
||||
if client is None:
|
||||
raise HeaderValidationError(f"the header at height {height} does not join the tip and there is no connection to follow it")
|
||||
lowest = min(self._window)
|
||||
ours = [self._window[h] for h in range(lowest, self.tip_height + 1)]
|
||||
theirs = await self._fetch_headers(client, lowest, len(ours))
|
||||
fork_height = None
|
||||
for offset in range(len(ours) - 1, -1, -1):
|
||||
if header_hex_to_block_hash(theirs[offset]) == header_hex_to_block_hash(ours[offset]):
|
||||
fork_height = lowest + offset
|
||||
break
|
||||
if fork_height is None:
|
||||
raise HeaderValidationError(
|
||||
f"the header at height {height} forks more than {_HEADER_WINDOW} blocks below our tip {self.tip_height}"
|
||||
)
|
||||
branch = await self._fetch_headers(client, fork_height + 1, height - fork_height)
|
||||
if branch[-1] != header_hex:
|
||||
raise HeaderValidationError(f"the server's branch does not end at the header it announced at height {height}")
|
||||
self._check_chained(branch, header_hex_to_block_hash(self._window[fork_height]))
|
||||
old_height, old_hex = self.tip_height, self.tip_header_hex
|
||||
# Recorded *before* the window moves: if the write fails the session ends, the next
|
||||
# server announces the same fork against an unchanged window, and the event is
|
||||
# written then — a reorg is never followed without a trace.
|
||||
await self._record_reorg(
|
||||
depth=old_height - fork_height,
|
||||
fork_height=fork_height,
|
||||
old_tip_height=old_height,
|
||||
old_tip_hash=header_hex_to_block_hash(old_hex),
|
||||
new_tip_height=height,
|
||||
new_tip_hash=header_hex_to_block_hash(header_hex),
|
||||
)
|
||||
self._extend_window([(fork_height + 1 + i, h) for i, h in enumerate(branch)])
|
||||
|
||||
async def _record_reorg(self, **payload) -> None:
|
||||
"""B-85 (ELC-24, AUD-38): the audit row of a followed reorganisation; a depth of 2
|
||||
or more also pauses the lottery (`paused_reason = 'reorg'`), in the same transaction
|
||||
as the row and as the `lottery_paused` one. Depth 1 is only observed: a one-block
|
||||
reorganisation is ordinary on any chain and touches no deposit."""
|
||||
logger.warning(
|
||||
"followed a chain reorganisation of depth %s: fork at height %s, tip %s -> %s",
|
||||
payload["depth"],
|
||||
payload["fork_height"],
|
||||
payload["old_tip_height"],
|
||||
payload["new_tip_height"],
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await begin_immediate(session)
|
||||
await write_audit_log(session, "chain_reorg_detected", payload)
|
||||
if payload["depth"] >= _REORG_PAUSE_DEPTH:
|
||||
await pause_automatically(session, "reorg")
|
||||
await session.commit()
|
||||
|
||||
async def _corroborate_majority(
|
||||
self,
|
||||
@@ -301,60 +542,257 @@ class ElectrumListener:
|
||||
agrees: Callable[[object], bool],
|
||||
description: str,
|
||||
) -> bool:
|
||||
"""Shared quorum logic behind corroborate_header (B-28) and
|
||||
corroborate_utxo_spent (B-29): ask every *other* configured server (never
|
||||
"""Shared quorum logic behind
|
||||
corroborate_utxo_spent (B-29), credits and reinstatements: ask every *other* configured server (never
|
||||
the currently active one — that's exactly what a hostile server or a MITM
|
||||
would control) and require a strict majority of the ones that actually
|
||||
answer to agree, via `agrees`, with what our own connection reported.
|
||||
|
||||
Returns True with no other servers configured — nothing to corroborate
|
||||
against, a risk accepted when ELECTRUM_FALLBACK_SERVERS was left empty
|
||||
(see CLAUDE.md). Returns False (never silently "passes") if none of the
|
||||
others could be reached, since an unreachable network proves nothing
|
||||
either way.
|
||||
The quorum is strict (B-84, ELC-14): it passes only when **at least
|
||||
MIN_QUORUM_RESPONSES** of the other servers answered *and* a strict majority of
|
||||
those that answered agree. Fewer answers — one reachable server, or none, or no
|
||||
other server configured at all — fail, since a lone voice decides nothing
|
||||
independently and an unreachable network proves nothing. Startup already refuses
|
||||
to run with fewer than two fallbacks (config.validate_quorum_servers), so with a
|
||||
sound configuration "too few answered" means servers are down, and the
|
||||
corroboration simply fails (a delay, never a loss — the callers retry).
|
||||
|
||||
Only with ALLOW_NO_QUORUM=true (development) does the reduced rule apply: no
|
||||
other server configured passes, and otherwise a strict majority of whoever
|
||||
answered decides, even a single answer.
|
||||
"""
|
||||
others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
if not others:
|
||||
if not others and self._allow_no_quorum:
|
||||
return True
|
||||
if not others:
|
||||
logger.warning("could not corroborate %s: no other Electrum server is configured", description)
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*(ask(endpoint) for endpoint in others))
|
||||
responded = [result for result in results if result is not None]
|
||||
if not responded:
|
||||
minimum = 1 if self._allow_no_quorum else MIN_QUORUM_RESPONSES
|
||||
if len(responded) < minimum:
|
||||
logger.warning(
|
||||
"could not corroborate %s with any of %s other configured server(s)", description, len(others)
|
||||
"could not corroborate %s: %s of %s other configured server(s) answered, %s needed",
|
||||
description,
|
||||
len(responded),
|
||||
len(others),
|
||||
minimum,
|
||||
)
|
||||
return False
|
||||
|
||||
agreements = sum(1 for result in responded if agrees(result))
|
||||
return agreements * 2 > len(responded)
|
||||
|
||||
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
|
||||
"""B-28: is `expected_hash` — the header our own active connection
|
||||
reported for `height` — corroborated by other configured servers before
|
||||
the draw (rounds/scheduler.py:_wait_for_next_block) treats it as
|
||||
trustworthy entropy? Without this, a single hostile server (or a MITM on
|
||||
the one active connection) can single-handedly decide who wins every
|
||||
round; this raises the bar to controlling a majority of the configured
|
||||
servers. See _corroborate_majority for the shared quorum logic.
|
||||
"""
|
||||
# --- Asking every server (B-76, TX-04, TX-21) ---------------------------------
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
|
||||
def _other_endpoints(self) -> list[ElectrumEndpoint]:
|
||||
return [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint]
|
||||
|
||||
def _minimum_other_answers(self) -> int:
|
||||
"""How many *other* servers must answer before their silence or agreement
|
||||
counts as the network's: MIN_QUORUM_RESPONSES, or the reduced development rule
|
||||
of ALLOW_NO_QUORUM (one answer, none if no other server is configured)."""
|
||||
if not self._allow_no_quorum:
|
||||
return MIN_QUORUM_RESPONSES
|
||||
return 1 if self._other_endpoints() else 0
|
||||
|
||||
async def _ask_other_servers(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> list:
|
||||
"""`ask(client)` on a fresh connection to every endpoint but the active one, in
|
||||
parallel, each bounded by _CORROBORATION_TIMEOUT_SECONDS; a server that cannot be
|
||||
reached or does not answer in time yields None. `ask` itself decides what an
|
||||
answer is (it may return None for an unusable one)."""
|
||||
|
||||
async def _one(endpoint: ElectrumEndpoint):
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
result = await asyncio.wait_for(
|
||||
client.request("blockchain.block.header", [height]),
|
||||
timeout=_CORROBORATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not isinstance(result, str):
|
||||
return None
|
||||
return header_hex_to_block_hash(result)
|
||||
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header")
|
||||
return list(await asyncio.gather(*(_one(endpoint) for endpoint in self._other_endpoints())))
|
||||
|
||||
async def _ask_active_server(self, ask: Callable[[ElectrumClient], "asyncio.Future"]):
|
||||
client = self.client
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
return await asyncio.wait_for(ask(client), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None:
|
||||
"""Does the network know `txid`? Asked of the active server and every other
|
||||
configured one with `blockchain.transaction.get` (B-76, TX-04):
|
||||
|
||||
* True — at least one server returns the transaction: it is on the network,
|
||||
whatever the server that refused it said;
|
||||
* False — every server that answered says it does not know it, and the active
|
||||
server and the minimum number of the others (see _minimum_other_answers) are
|
||||
among them: nobody has it;
|
||||
* None — too few answers to tell. Nothing may be released on this.
|
||||
"""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> bool | None:
|
||||
try:
|
||||
raw = await client.get_transaction(txid, False)
|
||||
except ElectrumRpcError:
|
||||
return False # an explicit "no such transaction"
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if isinstance(raw, str) and Transaction.parse(bytes.fromhex(raw)).txid().hex() == txid:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return None # a reply that is not this transaction proves nothing either way
|
||||
|
||||
active = await self._ask_active_server(_ask)
|
||||
others = await self._ask_other_servers(_ask)
|
||||
if active is True or any(answer is True for answer in others):
|
||||
return True
|
||||
answered_others = [answer for answer in others if answer is not None]
|
||||
if active is False and len(answered_others) >= self._minimum_other_answers():
|
||||
return False
|
||||
return None
|
||||
|
||||
def fan_out_transaction(self, raw_tx_hex: str) -> None:
|
||||
"""Send an accepted transaction to every other configured server too (B-76,
|
||||
TX-21): a second route onto the network in case the active server's own relay
|
||||
is weak. Best effort and off the caller's path — a failure is only logged."""
|
||||
|
||||
async def _send() -> None:
|
||||
async def _broadcast(client: ElectrumClient) -> str:
|
||||
return await client.broadcast(raw_tx_hex)
|
||||
|
||||
results = await self._ask_other_servers(_broadcast)
|
||||
logger.info(
|
||||
"fanned a transaction out to %s other server(s), %s answered", len(results), sum(r is not None for r in results)
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_send())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
# --- What the network knows of a transaction (B-78, TX-15, TX-17) -------------------
|
||||
|
||||
async def _ask_everyone(self, ask: Callable[[ElectrumClient], "asyncio.Future"]) -> ServerAnswers:
|
||||
active, others = await asyncio.gather(self._ask_active_server(ask), self._ask_other_servers(ask))
|
||||
return ServerAnswers(active, tuple(others), self._minimum_other_answers())
|
||||
|
||||
async def get_histories(self, scripthash: str) -> ServerAnswers:
|
||||
"""`blockchain.scripthash.get_history` of `scripthash` from the active server and
|
||||
every other one: each answer is the list of {"tx_hash", "height"} entries."""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> list | None:
|
||||
try:
|
||||
history = await client.get_history(scripthash)
|
||||
except Exception:
|
||||
return None
|
||||
return history if isinstance(history, list) else None
|
||||
|
||||
return await self._ask_everyone(_ask)
|
||||
|
||||
async def rebroadcast_everywhere(self, raw_tx_hex: str) -> ServerAnswers:
|
||||
"""Send `raw_tx_hex` to the active server and every other one and report what each
|
||||
said: ("accepted", "") or ("refused", <its message>) — an explicit JSON-RPC error —
|
||||
and None for a server that gave no answer at all (TX-17). A tx the servers already
|
||||
have comes back as a refusal with an "already known" message; the caller reads it."""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> tuple | None:
|
||||
try:
|
||||
await client.broadcast(raw_tx_hex)
|
||||
except ElectrumRpcError as exc:
|
||||
return ("refused", exc.message)
|
||||
except Exception:
|
||||
return None
|
||||
return ("accepted", "")
|
||||
|
||||
return await self._ask_everyone(_ask)
|
||||
|
||||
async def confirmed_spender(
|
||||
self, scripthash: str, own_txids: set[str], outpoints: set[tuple[str, int]]
|
||||
) -> str | None:
|
||||
"""The txid of a transaction, none of the row's own versions, that spends one of
|
||||
`outpoints` and is **in a block** — the first proof by which a row may be given
|
||||
up (TX-17): its inputs went elsewhere. Asked of the *other* servers only (the
|
||||
active one is what a hostile peer would control) and returned only when at least
|
||||
the quorum minimum answered and a strict majority of the answerers name the same
|
||||
txid. Each server looks through the confirmed history of the address the row
|
||||
spends from.
|
||||
"""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> str | None:
|
||||
history = await client.get_history(scripthash)
|
||||
for entry in history:
|
||||
txid = entry.get("tx_hash")
|
||||
if txid in own_txids or (entry.get("height") or 0) <= 0:
|
||||
continue
|
||||
raw = await client.get_transaction(txid, False)
|
||||
tx = Transaction.parse(bytes.fromhex(raw))
|
||||
if tx.txid().hex() != txid:
|
||||
continue
|
||||
if any((vin.txid.hex(), vin.vout) in outpoints for vin in tx.vin):
|
||||
return txid
|
||||
return "" # looked everywhere: no conflicting transaction
|
||||
|
||||
answers = await self._ask_other_servers(_ask)
|
||||
answered = [answer for answer in answers if answer is not None]
|
||||
if len(answered) < max(self._minimum_other_answers(), 1):
|
||||
return None
|
||||
counts = Counter(answer for answer in answered if answer)
|
||||
if not counts:
|
||||
return None
|
||||
txid, votes = counts.most_common(1)[0]
|
||||
return txid if votes * 2 > len(answered) else None
|
||||
|
||||
async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]:
|
||||
"""B-73 (RND-16, ELC-15): the hash of the block at exactly `height` — the draw's
|
||||
entropy — as the *quorum* agrees on it, and what the active server said about it.
|
||||
|
||||
The header at that height (`blockchain.block.header`) is asked of every configured
|
||||
server, the active one included, each over its own connection and within ELC-14's
|
||||
timeouts. A header only counts if it is 80 bytes of valid work (ELC-11). The hash is
|
||||
returned only when at least the quorum minimum of the *other* servers answered and a
|
||||
strict majority of everybody who answered names the same one; otherwise the first
|
||||
element is None and the caller asks again for **the same height** — the draw never
|
||||
moves to a different block, so whoever relays blocks cannot pick one. The second
|
||||
element is the active server's own answer (None if it gave none), for the audit row
|
||||
written on a disagreement.
|
||||
|
||||
The old rule asked only the *other* servers to confirm the block the active one had
|
||||
announced first after the close, which still let the active server choose which real
|
||||
block seeded the draw simply by never announcing another.
|
||||
"""
|
||||
|
||||
async def _ask(client: ElectrumClient) -> str | None:
|
||||
header = await client.get_block_header(height)
|
||||
if not isinstance(header, str) or not header_meets_its_own_target(header):
|
||||
return None
|
||||
return header_hex_to_block_hash(header)
|
||||
|
||||
answers = await self._ask_everyone(_ask)
|
||||
reported = answers.active
|
||||
if len(answers.answered_others) < self._minimum_other_answers():
|
||||
logger.warning(
|
||||
"block %s: %s of the other servers answered, %s needed", height, len(answers.answered_others),
|
||||
self._minimum_other_answers(),
|
||||
)
|
||||
return None, reported
|
||||
votes = Counter(answers.all)
|
||||
if not votes:
|
||||
return None, reported
|
||||
block_hash, count = votes.most_common(1)[0]
|
||||
if count * 2 <= len(answers.all):
|
||||
return None, reported
|
||||
if reported is not None and reported != block_hash:
|
||||
logger.warning(
|
||||
"block %s: the active server reported %s but the other servers agree on %s", height, reported, block_hash
|
||||
)
|
||||
return block_hash, reported
|
||||
|
||||
async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool:
|
||||
"""B-29: before deposits/service.py's find_utxos_missing_from candidates
|
||||
@@ -381,11 +819,125 @@ class ElectrumListener:
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
|
||||
|
||||
async def _consume_headers(self, queue: asyncio.Queue) -> None:
|
||||
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
|
||||
way. A candidate external spend could not reduce a balance without a
|
||||
quorum, but `value` and `height` for a *credit* were taken from the single
|
||||
active connection and written straight to the DB — so one hostile or broken
|
||||
server could inflate a user's displayed balance with outpoints that don't
|
||||
exist. That never spends anyone else's coins (a bet or withdrawal built on
|
||||
a phantom UTXO is refused at broadcast and rolled back), but it wedges the
|
||||
balance display and burns build attempts, and on a custodial platform a
|
||||
balance that isn't real is a support incident either way.
|
||||
|
||||
Agreement means: this server also reports the outpoint as unspent, for the
|
||||
same amount, and considers it confirmed. The height itself is not compared —
|
||||
a server still catching up reports the entry at height 0 and simply doesn't
|
||||
agree, which is the same answer, while for a genuinely confirmed outpoint
|
||||
two honest servers cannot disagree on the height anyway.
|
||||
|
||||
A failure here delays a credit, it never loses one: the next scripthash
|
||||
notification or DepositReconciler sweep (300s) retries it, and a deposit is
|
||||
only credited once the quorum agrees.
|
||||
"""
|
||||
|
||||
return await self._corroborate_unspent_confirmed(
|
||||
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
|
||||
)
|
||||
|
||||
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
|
||||
"""B-83 (ELC-25, DEP-08): the same quorum as a credit, for an outpoint that was
|
||||
written off as spent outside the platform and has reappeared among the unspent.
|
||||
Balances move in both directions and both need the same agreement: reinstating
|
||||
on the active server's word alone let a hostile one put a spent coin back into a
|
||||
balance."""
|
||||
return await self._corroborate_unspent_confirmed(
|
||||
scripthash, txid, vout, value, f"reinstatement of {value} sats at {txid}:{vout}"
|
||||
)
|
||||
|
||||
async def _corroborate_unspent_confirmed(
|
||||
self, scripthash: str, txid: str, vout: int, value: int, description: str
|
||||
) -> bool:
|
||||
"""Do the other servers list this outpoint as unspent, for the same amount, and
|
||||
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
entries = await asyncio.wait_for(
|
||||
client.listunspent(scripthash), timeout=_CORROBORATION_TIMEOUT_SECONDS
|
||||
)
|
||||
return any(
|
||||
e.get("tx_hash") == txid
|
||||
and e.get("tx_pos") == vout
|
||||
and e.get("value") == value
|
||||
and (e.get("height") or 0) > 0
|
||||
for e in entries
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
|
||||
|
||||
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
|
||||
|
||||
async def _header_at(self, height: int) -> str | None:
|
||||
"""The header of block `height` that a merkle proof is checked against: the tip
|
||||
we hold (validated as it arrived), or for an older block the one a strict
|
||||
majority of at least the quorum minimum of the other servers agree on, which must
|
||||
also satisfy its own difficulty target. None when neither is available — in
|
||||
particular for a height above our tip, which we have no way to check."""
|
||||
if height <= 0 or height > self.tip_height:
|
||||
return None
|
||||
if height == self.tip_height and self.tip_header_hex:
|
||||
return self.tip_header_hex
|
||||
if height in self._window:
|
||||
return self._window[height] # validated as it arrived (ELC-22)
|
||||
|
||||
async def _ask(client: ElectrumClient) -> str | None:
|
||||
header = await client.get_block_header(height)
|
||||
return header if isinstance(header, str) and header_meets_its_own_target(header) else None
|
||||
|
||||
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
|
||||
if len(answers) < max(self._minimum_other_answers(), 1):
|
||||
return None
|
||||
header, votes = Counter(answers).most_common(1)[0]
|
||||
return header if votes * 2 > len(answers) else None
|
||||
|
||||
async def confirmation_proven(self, txid: str, height: int) -> bool:
|
||||
"""Is `txid` in the block at `height`? Accepted only when the quorum returns a
|
||||
valid merkle proof — `blockchain.transaction.get_merkle` from the *other* servers
|
||||
(never the active one, which is what a hostile peer controls) that recomputes to
|
||||
the merkle root of that block's header, which we hold or the quorum agrees on. A
|
||||
strict majority of at least the quorum minimum must prove it; otherwise the answer
|
||||
is False and the caller simply asks again at its next pass (a delay, never a
|
||||
loss)."""
|
||||
header = await self._header_at(height)
|
||||
if header is None:
|
||||
logger.warning("cannot check the merkle proof of %s at height %s: no trusted header for it", txid, height)
|
||||
return False
|
||||
|
||||
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
|
||||
client = self._client_factory(endpoint)
|
||||
try:
|
||||
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
proof = await asyncio.wait_for(client.get_merkle(txid, height), timeout=_CORROBORATION_TIMEOUT_SECONDS)
|
||||
return proof_matches_header(txid, height, proof, header)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return await self._corroborate_majority(_ask, lambda proven: proven, f"merkle proof of {txid} at height {height}")
|
||||
|
||||
async def _consume_headers(self, queue: asyncio.Queue, client: ElectrumClient | None = None) -> None:
|
||||
while True:
|
||||
params = await queue.get()
|
||||
for header in params:
|
||||
self._apply_header(header)
|
||||
await self._apply_header(header, client)
|
||||
# A new block is exactly what the "drawing" phase is waiting on
|
||||
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
|
||||
# refetch instead of waiting for their next poll.
|
||||
@@ -400,20 +952,63 @@ class ElectrumListener:
|
||||
|
||||
async def refresh_user(self, user_id: int, scripthash: str) -> None:
|
||||
"""Three phases, so no DB session is held across a network call (B-18),
|
||||
same shape as _trigger_payout: read what's needed, corroborate any
|
||||
candidate external spends against other servers (B-29), then persist.
|
||||
same shape as _trigger_payout: read what's needed, corroborate every
|
||||
balance-moving candidate against other servers — new credits (B-59) as
|
||||
well as candidate external spends (B-29) — then persist.
|
||||
"""
|
||||
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)
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, entries)
|
||||
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
|
||||
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
|
||||
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
|
||||
candidates = [
|
||||
(row.id, row.txid, row.vout)
|
||||
for row in await find_utxos_missing_from(session, user_id, entries)
|
||||
]
|
||||
|
||||
corroborated_credits = [
|
||||
entry
|
||||
for entry in credit_candidates
|
||||
if await self.corroborate_utxo_credit(
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
|
||||
)
|
||||
]
|
||||
|
||||
# B-83 (ELC-25, DEP-08): a coin written off as spent outside the platform comes back
|
||||
# into the balance only on the same quorum as a credit.
|
||||
corroborated_reinstatements = [
|
||||
entry
|
||||
for entry in reinstate_candidates
|
||||
if await self.corroborate_utxo_reinstatement(
|
||||
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
|
||||
)
|
||||
]
|
||||
reinstated = 0
|
||||
if corroborated_reinstatements:
|
||||
async with self._session_factory() as session:
|
||||
reinstated = await reinstate_reappeared_utxos(session, user_id, corroborated_reinstatements)
|
||||
if len(corroborated_reinstatements) < len(reinstate_candidates):
|
||||
logger.warning(
|
||||
"%s reappeared UTXO(s) for user_id=%s not corroborated by the other servers — "
|
||||
"left out of the balance, will retry on the next refresh",
|
||||
len(reinstate_candidates) - len(corroborated_reinstatements),
|
||||
user_id,
|
||||
)
|
||||
|
||||
credited = 0
|
||||
if corroborated_credits:
|
||||
async with self._session_factory() as session:
|
||||
credited = await credit_confirmed_utxos(session, user_id, corroborated_credits)
|
||||
if len(corroborated_credits) < len(credit_candidates):
|
||||
logger.warning(
|
||||
"%s new UTXO(s) for user_id=%s not corroborated by the other servers — "
|
||||
"not credited yet, will retry on the next refresh",
|
||||
len(credit_candidates) - len(corroborated_credits),
|
||||
user_id,
|
||||
)
|
||||
|
||||
confirmed_ids = [
|
||||
utxo_id
|
||||
for utxo_id, txid, vout in candidates
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Merkle proofs (B-83, TX-22, ELC-26).
|
||||
|
||||
`blockchain.transaction.get_merkle` answers with the branch that links a transaction to
|
||||
its block's merkle root. Checking it against a header **we hold** is what turns "a server
|
||||
says this tx is at height h" into something a hostile server cannot fake without mining.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
_HEADER_LENGTH_BYTES = 80
|
||||
_MERKLE_ROOT_OFFSET = 36
|
||||
_MERKLE_ROOT_LENGTH = 32
|
||||
_MAX_BRANCH_LENGTH = 40 # 2^40 transactions in a block is already absurd
|
||||
|
||||
|
||||
def _sha256d(data: bytes) -> bytes:
|
||||
return hashlib.sha256(hashlib.sha256(data).digest()).digest()
|
||||
|
||||
|
||||
def header_merkle_root(header_hex: str) -> bytes:
|
||||
"""The merkle root committed to by a raw 80-byte header, in internal byte order."""
|
||||
header = bytes.fromhex(header_hex)
|
||||
if len(header) != _HEADER_LENGTH_BYTES:
|
||||
raise ValueError(f"a block header is {_HEADER_LENGTH_BYTES} bytes, got {len(header)}")
|
||||
return header[_MERKLE_ROOT_OFFSET : _MERKLE_ROOT_OFFSET + _MERKLE_ROOT_LENGTH]
|
||||
|
||||
|
||||
def merkle_root_from_branch(txid: str, branch: list[str], position: int) -> bytes:
|
||||
"""The merkle root (internal byte order) a transaction `txid` at index `position` of
|
||||
its block reaches through `branch` — the sibling hashes, bottom-up, in the display
|
||||
order Electrum uses for txids."""
|
||||
if position < 0 or len(branch) > _MAX_BRANCH_LENGTH or position >> len(branch):
|
||||
raise ValueError("merkle position does not fit the branch")
|
||||
current = bytes.fromhex(txid)[::-1]
|
||||
index = position
|
||||
for sibling_hex in branch:
|
||||
sibling = bytes.fromhex(sibling_hex)[::-1]
|
||||
if len(sibling) != 32:
|
||||
raise ValueError("merkle branch element is not 32 bytes")
|
||||
current = _sha256d(sibling + current) if index & 1 else _sha256d(current + sibling)
|
||||
index >>= 1
|
||||
return current
|
||||
|
||||
|
||||
def proof_matches_header(txid: str, height: int, proof: object, header_hex: str) -> bool:
|
||||
"""Does `proof` — a `get_merkle` reply — put `txid` in the block at `height` whose
|
||||
header is `header_hex`? Any malformed reply is simply "no"."""
|
||||
try:
|
||||
if not isinstance(proof, dict) or int(proof["block_height"]) != height:
|
||||
return False
|
||||
branch = proof["merkle"]
|
||||
if not isinstance(branch, list):
|
||||
return False
|
||||
root = merkle_root_from_branch(txid, [str(item) for item in branch], int(proof["pos"]))
|
||||
return root == header_merkle_root(header_hex)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
@@ -0,0 +1,27 @@
|
||||
"""What a question put to every server came back with (B-78).
|
||||
|
||||
`active` is the answer of the connection the platform is running on, `others` those of
|
||||
each other configured server in configuration order; `None` stands for "no usable
|
||||
answer" (unreachable, timed out, an error that is not an answer)."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class ServerAnswers(NamedTuple):
|
||||
active: object | None
|
||||
others: tuple
|
||||
others_needed: int # how many of `others` must have answered for the network to count
|
||||
|
||||
@property
|
||||
def answered_others(self) -> list:
|
||||
return [answer for answer in self.others if answer is not None]
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Enough of the network answered for its silence to mean something: the active
|
||||
server and at least `others_needed` of the others."""
|
||||
return self.active is not None and len(self.answered_others) >= self.others_needed
|
||||
|
||||
@property
|
||||
def all(self) -> list:
|
||||
return ([] if self.active is None else [self.active]) + self.answered_others
|
||||
+10
-6
@@ -22,7 +22,7 @@ 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.api.errors import ApiError
|
||||
from app.config import settings, validate_runtime_secrets
|
||||
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.deposits.reconcile import DepositReconciler
|
||||
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
|
||||
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl)
|
||||
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -50,19 +50,23 @@ async def lifespan(app: FastAPI):
|
||||
settings.electrum_port,
|
||||
settings.electrum_use_ssl,
|
||||
settings.electrum_fallback_servers,
|
||||
settings.electrum_cert_pin,
|
||||
)
|
||||
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
||||
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
||||
|
||||
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal, endpoints)
|
||||
listener = ElectrumListener(
|
||||
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
|
||||
)
|
||||
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)
|
||||
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
|
||||
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
|
||||
# Resolves in-flight transactions against the chain — the piece that lets the
|
||||
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
|
||||
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client)
|
||||
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client, network=listener)
|
||||
# Periodic safety net for deposit crediting/external-spend detection,
|
||||
# independent of scripthash-change notifications — catches a subscription
|
||||
# silently lost on an otherwise healthy connection (B-30).
|
||||
|
||||
+67
-11
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
|
||||
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
|
||||
@@ -9,6 +10,16 @@ _BITS_OFFSET = 72
|
||||
_BITS_LENGTH = 4
|
||||
|
||||
|
||||
# B-74 (ELC-11): the proof-of-work floor of PLM mainnet — `consensus.powLimit` in
|
||||
# palladiumcore/src/chainparams.cpp, the same as Bitcoin's. A header claiming a target
|
||||
# *easier* than this was not mined to the chain's rules, whatever its own hash says, so
|
||||
# the claimed target alone (which is whoever fabricates the header's to choose) is not a
|
||||
# proof of work. Read through the module at call time so a test can swap in a regtest
|
||||
# limit to mine headers cheaply; production code never changes it.
|
||||
MAINNET_POW_LIMIT = int("00000000" + "ff" * 28, 16)
|
||||
POW_LIMIT = MAINNET_POW_LIMIT
|
||||
|
||||
|
||||
class HeaderValidationError(Exception):
|
||||
"""Raised by ElectrumListener._apply_header (B-28) when a header either doesn't
|
||||
satisfy the difficulty target it claims for itself, or doesn't chain from the
|
||||
@@ -48,7 +59,8 @@ def _target_from_bits(bits: int) -> int:
|
||||
|
||||
def header_meets_its_own_target(header_hex: str) -> bool:
|
||||
"""Whether this header's hash satisfies the difficulty target *it claims for
|
||||
itself* (the `bits` field). Rejects a header that was never actually mined —
|
||||
itself* (the `bits` field), and that target is no easier than the chain's powLimit
|
||||
(B-74, ELC-11 — without that, a fabricated header just claims `bits = 0x207fffff`). Rejects a header that was never actually mined —
|
||||
e.g. one fabricated wholesale by a hostile or MITM'd Electrum server (B-28),
|
||||
since satisfying a self-chosen target still requires real proof-of-work.
|
||||
|
||||
@@ -57,8 +69,8 @@ def header_meets_its_own_target(header_hex: str) -> bool:
|
||||
a real but implausibly low self-chosen difficulty: a server could still declare
|
||||
an easy target and grind it out with modest hardware. That residual risk is why
|
||||
the draw additionally requires the winning block's header to be corroborated by
|
||||
the *other* configured servers before using it as the seed (see
|
||||
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
|
||||
the configured servers at a fixed height before using it as the seed (see
|
||||
ElectrumListener.agreed_block_hash and rounds/scheduler.py:_wait_for_draw_block)
|
||||
rather than relying on this check alone.
|
||||
"""
|
||||
header_bytes = bytes.fromhex(header_hex)
|
||||
@@ -66,7 +78,7 @@ def header_meets_its_own_target(header_hex: str) -> bool:
|
||||
return False
|
||||
bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little")
|
||||
target = _target_from_bits(bits)
|
||||
if target <= 0:
|
||||
if target <= 0 or target > POW_LIMIT: # B-74: not easier than the chain's powLimit
|
||||
return False
|
||||
digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest()
|
||||
# The hash as the integer comparable against `target`: this is the same digest
|
||||
@@ -77,12 +89,56 @@ def header_meets_its_own_target(header_hex: str) -> bool:
|
||||
return hash_int <= target
|
||||
|
||||
|
||||
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."""
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def new_server_seed() -> str:
|
||||
"""B-73 (RND-34, DAT-64): a round's secret — 32 random bytes from the OS CSPRNG, as
|
||||
64 lowercase hex characters."""
|
||||
return secrets.token_bytes(32).hex()
|
||||
|
||||
|
||||
def server_seed_commitment(server_seed_hex: str) -> str:
|
||||
"""`server_seed_hash`: sha256 of the secret's 32 bytes, published from the opening of
|
||||
the round so the operator cannot change the secret afterwards (RND-34)."""
|
||||
return sha256_hex(bytes.fromhex(server_seed_hex))
|
||||
|
||||
|
||||
def participants_hash(rows: list[tuple[str, int, str]]) -> str:
|
||||
"""The fingerprint of the ordered draw list (RND-14): sha256 of one `txid:vout:address`
|
||||
line per participant, in `draw_position` order, each line ending in `\n`, UTF-8. `txid`
|
||||
is the bet transaction's, lowercase hex as explorers show it; `vout` is the output paying
|
||||
the round's pool address, in decimal; `address` is the participant's own."""
|
||||
text = "".join(f"{txid}:{vout}:{address}\n" for txid, vout, address in rows)
|
||||
return sha256_hex(text.encode("utf-8"))
|
||||
|
||||
|
||||
def draw_seed(server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> int:
|
||||
"""RND-17: sha256 of the secret (32 bytes), the hash of the block at `draw_height` (32
|
||||
bytes, in the order explorers show it) and `participants_hash` (32 bytes), read as an
|
||||
unsigned big-endian integer. Nobody who lacks the secret can predict it, and the
|
||||
operator, who knows the secret, cannot know the block when committing to the list."""
|
||||
digest = hashlib.sha256(
|
||||
bytes.fromhex(server_seed_hex) + bytes.fromhex(block_hash_hex) + bytes.fromhex(participants_hash_hex)
|
||||
).digest()
|
||||
return int.from_bytes(digest, "big")
|
||||
|
||||
|
||||
def draw_index(seed: int, participant_count: int) -> int:
|
||||
"""The winner's `draw_position`: seed modulo the number of participants. Every
|
||||
participant holds exactly one position, so odds are equal whatever the amount."""
|
||||
if participant_count <= 0:
|
||||
raise ValueError("no participants to draw from")
|
||||
return seed % participant_count
|
||||
|
||||
|
||||
def draw_winner(participants: list[str], server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> str:
|
||||
"""The v1 draw algorithm (docs/ciclo-di-vita-del-round.md RND-17), recomputable by anyone
|
||||
from the public proof (GET /rounds/{id}/proof): `participants` are the entries in
|
||||
`draw_position` order, the winner is the one at `draw_index(draw_seed(...), n)`. Deliberately
|
||||
simple and replaceable."""
|
||||
if not participants:
|
||||
raise ValueError("no participants to draw from")
|
||||
seed = int(block_hash_hex, 16)
|
||||
index = seed % len(participants)
|
||||
return participants[index]
|
||||
seed = draw_seed(server_seed_hex, block_hash_hex, participants_hash_hex)
|
||||
return participants[draw_index(seed, len(participants))]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Pausing the lottery from code (B-78, DAT-69, AUD-11).
|
||||
|
||||
A pause only stops the *next* round from opening (rounds/service.py:rounds_can_open); a
|
||||
round in progress still closes, draws and pays. Besides the operator's own switch there
|
||||
are pauses the system raises itself when something needs a human look — a transaction
|
||||
given up for dead that confirmed after all (`resurrected_tx`, B-78) and, in later
|
||||
findings, a deep reorg (`reorg`, B-85) and a payout whose winning bet left the chain
|
||||
(`payout_blocked`, B-82).
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.rounds.config import get_round_config
|
||||
|
||||
OPERATOR_REASON = "operator"
|
||||
AUTOMATIC_REASONS = ("reorg", "payout_blocked", "resurrected_tx")
|
||||
|
||||
|
||||
async def pause_automatically(session: AsyncSession, reason: str) -> None:
|
||||
"""Pause the lottery for an automatic `reason` in the caller's transaction and write
|
||||
the `lottery_paused` row. An automatic reason is written even over a pause that is
|
||||
already active (DAT-69): the cause that needs checking must not be hidden behind an
|
||||
earlier one, and an operator pause is never what overrides it."""
|
||||
if reason not in AUTOMATIC_REASONS:
|
||||
raise ValueError(f"not an automatic pause reason: {reason!r}")
|
||||
config = await get_round_config(session)
|
||||
config.paused = True
|
||||
config.paused_reason = reason
|
||||
await write_audit_log(session, "lottery_paused", {"reason": reason})
|
||||
+291
-97
@@ -5,17 +5,28 @@ from datetime import datetime, timedelta, timezone
|
||||
from embit import script
|
||||
from embit.transaction import Transaction
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import AuditLog, 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.draw import (
|
||||
draw_index,
|
||||
draw_seed,
|
||||
new_server_seed,
|
||||
participants_hash,
|
||||
server_seed_commitment,
|
||||
)
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.service import open_new_round_if_needed, winner_share
|
||||
from app.wallet.hd import derive_pool_key
|
||||
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
|
||||
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
|
||||
from app.tx.versions import record_version
|
||||
from app.wallet.hd import derive_round_pool_key
|
||||
from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
|
||||
|
||||
@@ -39,10 +50,10 @@ _DRAW_STALL_THRESHOLD_SECONDS = 360 # a few multiples of PLM's 120s block time
|
||||
|
||||
|
||||
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)."""
|
||||
"""Background task implementing the round lifecycle (docs/ciclo-di-vita-del-round.md):
|
||||
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
|
||||
@@ -63,12 +74,19 @@ class RoundScheduler:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
# B-88: every state transition below starts with BEGIN IMMEDIATE (DAT-74).
|
||||
# A busy database raises out of the tick, which run() logs and retries at
|
||||
# the next one — nothing has been written yet.
|
||||
await begin_immediate(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
|
||||
round_duration_seconds = (await get_round_config(session)).round_duration_seconds
|
||||
round_id, status = round_.id, round_.status
|
||||
# B-61: this round's own snapshotted deadline, not one recomputed from
|
||||
# whatever the config says now — an operator lowering the duration
|
||||
# mid-round used to close the round on the spot.
|
||||
deadline = round_deadline(round_)
|
||||
|
||||
if status == "paying_out":
|
||||
# B-26: _trigger_payout used to run exactly once, from _close_and_draw —
|
||||
@@ -83,11 +101,11 @@ class RoundScheduler:
|
||||
return # "drawing" — progress happens inside the in-flight _close_and_draw call
|
||||
|
||||
if status == "open":
|
||||
opened_at = opened_at.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds):
|
||||
if datetime.now(timezone.utc) < deadline:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
round_ = await session.get(Round, round_id)
|
||||
# No new bets from here on, regardless of how long the pending-bet
|
||||
# wait below takes — flip to "closing" immediately so it's observable
|
||||
@@ -117,50 +135,169 @@ class RoundScheduler:
|
||||
await self._close_and_draw(round_id)
|
||||
|
||||
async def _close_and_draw(self, round_id: int) -> None:
|
||||
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
|
||||
participant) or commit to the draw — fix the participant list, its fingerprint and the
|
||||
draw height, all in one transaction, and enter `drawing` (RND-13, RND-14, RND-33,
|
||||
DAT-65) — then run the draw itself (`_complete_draw`)."""
|
||||
async with self._session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
round_ = await session.get(Round, round_id)
|
||||
|
||||
# B-53: re-check for in-flight bets in the *same* session that snapshots
|
||||
# the participants. _tick's check ran in a session of its own, so a bet
|
||||
# committing its "building" row in between was counted by neither: the
|
||||
# round drew and paid out without it, while its sats still landed in the
|
||||
# pool. place_bet's compare-and-set on the round row is what makes that
|
||||
# window unreachable; this is the cheap second lock on the same door, and
|
||||
# it fails safe — the round stays "closing" and the next tick retries.
|
||||
pending_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(RoundParticipant)
|
||||
.where(
|
||||
RoundParticipant.round_id == round_id,
|
||||
RoundParticipant.status.in_(("building", "broadcast")),
|
||||
)
|
||||
)
|
||||
if pending_count:
|
||||
logger.info(
|
||||
"round %s: %s bet(s) still in flight at close time, waiting", round_id, pending_count
|
||||
)
|
||||
return
|
||||
|
||||
# B-73 (RND-14, DAT-66): the draw order is the bet txid, ascending — a value the
|
||||
# public can read off the chain, with no ties (two bets never share a txid). The
|
||||
# server-only `broadcast_at` it replaces could not be checked by anyone.
|
||||
participants = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
|
||||
.order_by(RoundParticipant.broadcast_at)
|
||||
.order_by(RoundParticipant.bet_txid)
|
||||
)
|
||||
).all()
|
||||
|
||||
if not participants:
|
||||
round_.status = "closed"
|
||||
# DAT-64: nothing was drawn, so the secret has nothing left to protect.
|
||||
if round_.server_seed_enc and not round_.server_seed:
|
||||
round_.server_seed = decrypt_round_seed(round_.server_seed_enc)
|
||||
await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
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
|
||||
# B-73 (RND-19, RND-33): draw_height is "the height we knew at closing, plus one", so
|
||||
# an unknown height (0) cannot be a base — the round stays "closing" and this is
|
||||
# tried again at the next step, with one audit row per round.
|
||||
tip_at_close = self._listener.tip_height
|
||||
if tip_at_close <= 0:
|
||||
already_written = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(AuditLog)
|
||||
.where(AuditLog.event_type == "draw_baseline_tip_unknown", AuditLog.round_id == round_id)
|
||||
)
|
||||
if not already_written:
|
||||
logger.warning("round %s: chain tip unknown at closing time, not entering drawing yet", round_id)
|
||||
await write_audit_log(session, "draw_baseline_tip_unknown", {}, round_id=round_id)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
users = {
|
||||
user.id: user
|
||||
for user in (
|
||||
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
|
||||
).all()
|
||||
}
|
||||
if not round_.server_seed_enc:
|
||||
# A round opened before B-73 has no commitment. Give it a secret now: it still
|
||||
# cannot be known to anyone who could steer the block, because the list and the
|
||||
# height are fixed below before that block exists — only the early publication
|
||||
# of the commitment is missing for this one round.
|
||||
seed = new_server_seed()
|
||||
round_.server_seed_hash = server_seed_commitment(seed)
|
||||
round_.server_seed_enc = encrypt_round_seed(seed)
|
||||
for position, participant in enumerate(participants):
|
||||
participant.draw_position = position
|
||||
round_.participants_hash = participants_hash(
|
||||
[(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
|
||||
)
|
||||
round_.draw_height = tip_at_close + 1
|
||||
round_.pool_amount_sats = sum(p.bet_amount_sats for p in participants)
|
||||
round_.status = "drawing"
|
||||
drawing_started_at = datetime.now(timezone.utc)
|
||||
round_.drawing_started_at = drawing_started_at
|
||||
round_.drawing_started_at = datetime.now(timezone.utc)
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_committed",
|
||||
{
|
||||
"participants_hash": round_.participants_hash,
|
||||
"draw_height": round_.draw_height,
|
||||
"participants": len(participants),
|
||||
"server_seed_hash": round_.server_seed_hash,
|
||||
},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
broadcaster.publish()
|
||||
|
||||
tip_at_close = self._listener.tip_height
|
||||
block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at)
|
||||
winner_address = draw_winner(addresses, block_hash)
|
||||
await self._complete_draw(round_id)
|
||||
|
||||
async def _complete_draw(self, round_id: int) -> None:
|
||||
"""The draw of a round in `drawing`, from nothing but what the database holds (RND-36,
|
||||
DAT-65): wait for the block at `draw_height` to be confirmed and agreed on by the
|
||||
quorum, compute the winner, store it and reveal the secret in one transaction, then
|
||||
start the payout. Idempotent — a round no longer in `drawing`, or with a winner
|
||||
already stored, is left alone."""
|
||||
async with self._session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
round_.draw_block_height = block_height
|
||||
if round_ is None or round_.status != "drawing" or round_.winner_user_id is not None:
|
||||
return
|
||||
draw_height = round_.draw_height
|
||||
draw_confirmations = round_.draw_confirmations
|
||||
committed_hash = round_.participants_hash
|
||||
server_seed_enc = round_.server_seed_enc
|
||||
server_seed_hash = round_.server_seed_hash
|
||||
waiting_since = round_.drawing_started_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
block_hash = await self._wait_for_draw_block(round_id, draw_height, draw_confirmations, waiting_since)
|
||||
|
||||
server_seed = decrypt_round_seed(server_seed_enc)
|
||||
if server_seed_commitment(server_seed) != server_seed_hash:
|
||||
raise RuntimeError(f"round {round_id}: the stored secret does not match its published commitment")
|
||||
|
||||
async with self._session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
round_ = await session.get(Round, round_id)
|
||||
if round_.status != "drawing" or round_.winner_user_id is not None:
|
||||
return # drawn meanwhile (a second pass over the same round)
|
||||
participants = (
|
||||
await session.scalars(
|
||||
select(RoundParticipant)
|
||||
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
|
||||
.order_by(RoundParticipant.draw_position)
|
||||
)
|
||||
).all()
|
||||
users = {
|
||||
user.id: user
|
||||
for user in (
|
||||
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
|
||||
).all()
|
||||
}
|
||||
ordered_rows = [(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
|
||||
if participants_hash(ordered_rows) != committed_hash:
|
||||
raise RuntimeError(f"round {round_id}: the draw list no longer matches its published fingerprint")
|
||||
seed = draw_seed(server_seed, block_hash, committed_hash)
|
||||
winner = participants[draw_index(seed, len(participants))]
|
||||
winner_address = users[winner.user_id].address
|
||||
pool_amount = sum(p.bet_amount_sats for p in participants if p.status == "confirmed")
|
||||
|
||||
round_.draw_block_height = draw_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_.seed_int = str(seed)
|
||||
round_.winner_user_id = winner.user_id
|
||||
round_.pool_amount_sats = pool_amount
|
||||
# The reveal (RND-18, DAT-64): the plaintext secret becomes visible in the same
|
||||
# transaction that stores the winner, never before.
|
||||
round_.server_seed = server_seed
|
||||
round_.status = "paying_out"
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -168,11 +305,15 @@ class RoundScheduler:
|
||||
{
|
||||
"winner_address": winner_address,
|
||||
"pool_amount_sats": pool_amount,
|
||||
"block_height": block_height,
|
||||
"block_height": draw_height,
|
||||
"block_hash": block_hash,
|
||||
"participants": len(addresses),
|
||||
"participants": len(participants),
|
||||
"draw_height": draw_height,
|
||||
"participants_hash": committed_hash,
|
||||
"server_seed": server_seed,
|
||||
"seed": f"{seed:064x}",
|
||||
},
|
||||
user_id=user_by_address[winner_address],
|
||||
user_id=winner.user_id,
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
@@ -181,75 +322,94 @@ class RoundScheduler:
|
||||
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, round_id: int, tip_at_close: int, waiting_since: datetime
|
||||
) -> tuple[int, str]:
|
||||
"""Waits for a block after tip_at_close and, before handing it back as the
|
||||
draw's entropy source, requires it to be corroborated by the other
|
||||
configured Electrum servers (B-28) — our own active connection is exactly
|
||||
the thing a hostile server or a MITM would control, so its header alone is
|
||||
not enough to seed a payout. A candidate that fails corroboration is never
|
||||
used: this keeps waiting for a further block and tries corroborating that
|
||||
one instead, logging why every time so a stuck draw is visible in
|
||||
/admin's audit log rather than a silent, unexplained wait.
|
||||
async def _wait_for_draw_block(
|
||||
self, round_id: int, draw_height: int, confirmations: int, waiting_since: datetime
|
||||
) -> str:
|
||||
"""The hash of the block at `draw_height`, once it is safe to seed the draw with it
|
||||
(RND-15, RND-16, RND-20; B-73): the chain must have `confirmations` blocks from that
|
||||
height (a tip of at least `draw_height + confirmations - 1`), and then the quorum of
|
||||
servers must agree on the header *at that exact height* — never on the tip, never on
|
||||
a later block. If they do not agree the same height is asked again at the next step
|
||||
and `draw_header_corroboration_failed` is written once per distinct hash the active
|
||||
server reported, so a disagreement that lasts does not write a row every 5 seconds.
|
||||
Whoever produces or relays blocks therefore cannot choose which block seeds the draw:
|
||||
withholding or delaying one only delays the draw.
|
||||
|
||||
This wait has no timeout — it can't, since the draw's entropy genuinely
|
||||
depends on a future block. B-36: what it lacked was *visibility*, so a
|
||||
connection that stopped advancing the tip left the round silently frozen
|
||||
in "drawing" with nothing in the logs or /admin to explain why. Progress
|
||||
is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a
|
||||
draw_stalled audit entry is written (and re-written every threshold
|
||||
interval for as long as the stall continues) so the wait shows up next
|
||||
to the draw_header_corroboration_failed entries above.
|
||||
The wait has no timeout — the entropy genuinely depends on a future block — but it is
|
||||
observable (B-36): progress is logged every _DRAW_PROGRESS_LOG_INTERVAL_SECONDS, and a
|
||||
`draw_stalled` row is written when for _DRAW_STALL_THRESHOLD_SECONDS the chain has not
|
||||
advanced while the round waits for confirmations, or the quorum has been failing to
|
||||
agree — and repeated every threshold for as long as that lasts. The clock restarts
|
||||
whenever the tip advances during the confirmation wait, so a chain that is merely
|
||||
producing its three blocks at the normal pace never counts as stalled.
|
||||
"""
|
||||
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
required_tip = draw_height + confirmations - 1
|
||||
now = datetime.now(timezone.utc)
|
||||
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
stall_since = now
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
last_tip = self._listener.tip_height
|
||||
agreeing = False # True once the confirmations are there and only the quorum is awaited
|
||||
reported_hashes: set[str | None] = set()
|
||||
|
||||
while True:
|
||||
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
|
||||
now = datetime.now(timezone.utc)
|
||||
if now >= next_progress_log_at:
|
||||
logger.info(
|
||||
"round %s: still waiting for a block past height %s (%.0fs since drawing started)",
|
||||
now = datetime.now(timezone.utc)
|
||||
tip = self._listener.tip_height
|
||||
if tip >= required_tip:
|
||||
if not agreeing:
|
||||
agreeing = True
|
||||
stall_since = now
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
block_hash, reported = await self._listener.agreed_block_hash(draw_height)
|
||||
if block_hash is not None:
|
||||
return block_hash
|
||||
if reported not in reported_hashes:
|
||||
reported_hashes.add(reported)
|
||||
logger.error(
|
||||
"round %s: block %s header (active server: %s) is not agreed by the other Electrum "
|
||||
"servers; asking again for the same height",
|
||||
round_id,
|
||||
tip_at_close,
|
||||
(now - waiting_since).total_seconds(),
|
||||
draw_height,
|
||||
reported,
|
||||
)
|
||||
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
if now >= next_stall_audit_at:
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_stalled",
|
||||
{
|
||||
"tip_at_close": tip_at_close,
|
||||
"current_tip_height": self._listener.tip_height,
|
||||
"elapsed_seconds": int((now - waiting_since).total_seconds()),
|
||||
},
|
||||
"draw_header_corroboration_failed",
|
||||
{"height": draw_height, "reported_hash": reported},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
height = self._listener.tip_height
|
||||
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
|
||||
if await self._listener.corroborate_header(height, block_hash):
|
||||
return height, block_hash
|
||||
logger.error(
|
||||
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
|
||||
"waiting for a further block",
|
||||
round_id,
|
||||
height,
|
||||
block_hash,
|
||||
)
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_header_corroboration_failed",
|
||||
{"height": height, "reported_hash": block_hash},
|
||||
round_id=round_id,
|
||||
elif tip != last_tip:
|
||||
last_tip = tip
|
||||
stall_since = now
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
|
||||
if now >= next_progress_log_at:
|
||||
logger.info(
|
||||
"round %s: still waiting for block %s (%s confirmation(s) needed, chain at %s, %.0fs since drawing started)",
|
||||
round_id,
|
||||
draw_height,
|
||||
confirmations,
|
||||
tip,
|
||||
(now - waiting_since).total_seconds(),
|
||||
)
|
||||
await session.commit()
|
||||
tip_at_close = height
|
||||
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
|
||||
if now >= next_stall_audit_at:
|
||||
async with self._session_factory() as session:
|
||||
await write_audit_log(
|
||||
session,
|
||||
"draw_stalled",
|
||||
{
|
||||
"draw_height": draw_height,
|
||||
"current_tip_height": tip,
|
||||
"elapsed_seconds": int((now - stall_since).total_seconds()),
|
||||
},
|
||||
round_id=round_id,
|
||||
)
|
||||
await session.commit()
|
||||
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
|
||||
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
|
||||
|
||||
async def _retry_payout_if_due(self, round_id: int) -> None:
|
||||
"""B-26: whether a "paying_out" round is due for another payout attempt.
|
||||
@@ -298,6 +458,12 @@ class RoundScheduler:
|
||||
|
||||
# --- Phase 1: read (session closed before any network I/O) ---------------
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
await begin_immediate(session) # B-88
|
||||
except OperationalError:
|
||||
# Nothing built or sent yet: try again at the next tick.
|
||||
logger.warning("round %s payout deferred: database busy", round_id)
|
||||
return
|
||||
round_ = await session.get(Round, round_id)
|
||||
already_in_flight = await session.scalar(
|
||||
select(PendingTransaction).where(
|
||||
@@ -322,6 +488,7 @@ class RoundScheduler:
|
||||
fee_address = config.fee_address
|
||||
fee_rate = config.fee_rate_sat_vb
|
||||
pool_amount_sats = round_.pool_amount_sats
|
||||
round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below)
|
||||
winner_user_id = round_.winner_user_id
|
||||
winner = await session.get(User, winner_user_id)
|
||||
winner_address = winner.address if winner is not None else None
|
||||
@@ -343,7 +510,10 @@ class RoundScheduler:
|
||||
|
||||
# --- Phase 2: build (network read only, no DB write yet) -----------------
|
||||
try:
|
||||
pool_key = derive_pool_key()
|
||||
# B-80 (RND-22): the round's own key and address — only the coins at this
|
||||
# round's pool address are ever read, so no other round's money (and no
|
||||
# stray coin sent to another address) can end up among the inputs.
|
||||
pool_key = derive_round_pool_key(round_id, round_pool_address)
|
||||
pool_script_obj = script.p2wpkh(pool_key.to_public())
|
||||
pool_address = pool_script_obj.address(network=PLM_MAINNET)
|
||||
entries = await client.listunspent(address_to_scripthash(pool_address))
|
||||
@@ -365,8 +535,12 @@ class RoundScheduler:
|
||||
fee_rate_sat_vb=fee_rate,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
# Includes the B-48 "too_many_inputs" case: the pool holds enough, but spread
|
||||
# over more UTXOs than one transaction may spend, so /admin has to say which.
|
||||
# Includes the "too_many_inputs" case: the pool holds enough, but spread over
|
||||
# more UTXOs than one transaction may spend, so /admin has to say which. Since
|
||||
# B-52 that means MAX_PAYOUT_TX_INPUTS, and participants are capped below it at
|
||||
# bet time (bets/service.py), so reaching it now takes pool change accumulated
|
||||
# over many rounds rather than one busy round — an operator consolidation job,
|
||||
# not a dead end for the bets of the round in progress.
|
||||
reason = "insufficient pool UTXOs" if exc.code == "insufficient_balance" else exc.code
|
||||
logger.exception("round %s payout failed: %s", round_id, reason)
|
||||
await self._log_payout_failure(round_id, winner_user_id, reason)
|
||||
@@ -383,6 +557,13 @@ class RoundScheduler:
|
||||
|
||||
# --- Phase 3: persist the intent, *then* broadcast (B-25) -----------------
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
await begin_immediate(session) # B-88
|
||||
except OperationalError:
|
||||
# Not written, not broadcast: the payout is simply rebuilt on the retry.
|
||||
logger.warning("round %s payout deferred before broadcast: database busy", round_id)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "database busy")
|
||||
return
|
||||
round_ = await session.get(Round, round_id)
|
||||
round_.winner_amount_sats = built.winner_sats
|
||||
round_.fee_amount_sats = built.commission_sats
|
||||
@@ -399,21 +580,34 @@ class RoundScheduler:
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version
|
||||
await session.commit()
|
||||
pending_id = pending.id
|
||||
|
||||
# --- Phase 4: broadcast, then promote the pending row --------------------
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception:
|
||||
# The row stays "building": the reconciler will ask the chain about it
|
||||
# and, finding nothing, abandon it and clear payout_txid (B-25) — instead
|
||||
# of the round being stuck with a payout_txid that never went anywhere.
|
||||
logger.exception("round %s payout broadcast failed", round_id)
|
||||
# B-76 (TX-05): the same three outcomes as a bet's broadcast. Accepted — the
|
||||
# server took it, or refused with "already known" / a server of the network
|
||||
# holds it — promotes the row; a refusal and an uncertain outcome both leave it
|
||||
# "building" with a payout_failed entry, since which of the two it really was
|
||||
# is the reconciler's call, made against the chain (TX-16, TX-17).
|
||||
verdict = await broadcast_with_verdict(client, self._listener, built.raw_hex, built.txid)
|
||||
if verdict.outcome is not BroadcastOutcome.ACCEPTED:
|
||||
logger.error("round %s payout broadcast not accepted (%s): %s", round_id, verdict.outcome.value, verdict.reason)
|
||||
await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected")
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
try:
|
||||
await begin_immediate(session) # B-88
|
||||
except OperationalError:
|
||||
# The tx is on the network; the row stays "building" and the
|
||||
# reconciler finds it on the chain and promotes it (TX-28).
|
||||
logger.error(
|
||||
"round %s payout %s broadcast but the database is busy: left 'building'",
|
||||
round_id,
|
||||
built.txid,
|
||||
)
|
||||
return
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
|
||||
+71
-10
@@ -5,9 +5,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Round
|
||||
from app.db.models import Round, RoundConfig
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.draw import new_server_seed, server_seed_commitment
|
||||
from app.wallet.hd import derive_pool_address
|
||||
from app.wallet.keystore import encrypt_round_seed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,6 +26,16 @@ _OPEN_ROUND_ATTEMPTS = 3
|
||||
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
|
||||
|
||||
|
||||
def _new_round_secret() -> dict:
|
||||
"""B-73 (RND-34): a fresh secret for a round about to open, as the columns that hold it —
|
||||
its published commitment and its encrypted form, never the plaintext."""
|
||||
server_seed = new_server_seed()
|
||||
return {
|
||||
"server_seed_hash": server_seed_commitment(server_seed),
|
||||
"server_seed_enc": encrypt_round_seed(server_seed),
|
||||
}
|
||||
|
||||
|
||||
def winner_share(pool_amount_sats: int) -> int:
|
||||
return pool_amount_sats * 70 // 100
|
||||
|
||||
@@ -48,7 +61,15 @@ async def get_active_round(session: AsyncSession) -> Round | None:
|
||||
return active[0] if active else None
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
def round_deadline(round_: Round) -> datetime:
|
||||
"""When this round stops accepting bets. B-61: from the round's own snapshotted
|
||||
duration, not from the live config — an operator editing round_duration_seconds
|
||||
mid-round must not move a deadline clients are already counting down to, nor
|
||||
close an in-progress round on the spot."""
|
||||
return round_.opened_at.replace(tzinfo=timezone.utc) + timedelta(seconds=round_.duration_seconds)
|
||||
|
||||
|
||||
def round_accepts_bets(round_: Round) -> bool:
|
||||
"""The authoritative "yellow light" check: once a round's timer has expired,
|
||||
no new bet may be accepted, even though its DB status is still "open" (the
|
||||
scheduler only flips it to "closing" on its next tick, up to
|
||||
@@ -57,17 +78,32 @@ def round_accepts_bets(round_: Round, round_duration_seconds: int) -> bool:
|
||||
before actually closing."""
|
||||
if round_.status != "open":
|
||||
return False
|
||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
||||
return datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds)
|
||||
return datetime.now(timezone.utc) < round_deadline(round_)
|
||||
|
||||
|
||||
def rounds_can_open(config: RoundConfig) -> bool:
|
||||
"""Whether the instance is configured well enough to run a round at all (B-66).
|
||||
|
||||
Only fee_address today, and only because a round without one is unpayable: the
|
||||
payout pays the 30% commission to it, so build_payout_transaction cannot even be
|
||||
built. It has no column default for exactly this reason (rounds/config.py) — an
|
||||
operator must set their own, and until they do there is nothing to guess.
|
||||
|
||||
Anything else that would make a round unpayable belongs here too, next to it,
|
||||
rather than being discovered at payout time. Deliberately not about *pausing*,
|
||||
which is a decision an operator took (RoundConfig.paused) rather than a
|
||||
prerequisite they haven't met yet."""
|
||||
return bool(config.fee_address.strip())
|
||||
|
||||
|
||||
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, or the lottery is paused for maintenance — in either 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.
|
||||
hasn't elapsed yet, the lottery is paused for maintenance, or the instance isn't
|
||||
configured well enough to pay a winner — in any of those cases 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.
|
||||
|
||||
Pausing never touches a round already in progress: it only suppresses opening
|
||||
the *next* one, so the current round still closes, draws, and pays out the
|
||||
@@ -79,15 +115,37 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
config = await get_round_config(session)
|
||||
if config.paused:
|
||||
return None
|
||||
if not rounds_can_open(config):
|
||||
# B-66: a fresh instance starts with no fee_address, and a round opened
|
||||
# without one takes bets, confirms them, and only then discovers that the
|
||||
# payout cannot be built — leaving the round wedged in "paying_out",
|
||||
# retrying every 60s, with money already in the pool. Every round would
|
||||
# need its own manual recovery. Refusing to open costs nothing by
|
||||
# comparison: no money has moved yet, and it is the operator's own missing
|
||||
# setup, surfaced through GET /rounds/current's lottery_configured and the
|
||||
# admin panel rather than discovered a round too late.
|
||||
return None
|
||||
|
||||
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=config.round_cooldown_seconds):
|
||||
# B-61: the cooldown the closing round announced is the one honoured, so
|
||||
# editing the config never retroactively shortens or extends a gap already
|
||||
# under way. The new value applies from the next round on.
|
||||
if datetime.now(timezone.utc) < closed_at + timedelta(seconds=last_closed.cooldown_seconds):
|
||||
return None
|
||||
|
||||
for attempt in range(_OPEN_ROUND_ATTEMPTS):
|
||||
round_ = Round(status="open")
|
||||
# B-61: the timing this round will run by, fixed at open time.
|
||||
round_ = Round(
|
||||
status="open",
|
||||
duration_seconds=config.round_duration_seconds,
|
||||
cooldown_seconds=config.round_cooldown_seconds,
|
||||
draw_confirmations=config.draw_confirmations, # B-73 (DAT-61)
|
||||
# B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the
|
||||
# plaintext column stays NULL until the reveal.
|
||||
**_new_round_secret(),
|
||||
)
|
||||
session.add(round_)
|
||||
try:
|
||||
await session.flush()
|
||||
@@ -111,6 +169,9 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
|
||||
# only tells subscribers "go refetch", and by the time an SSE client's
|
||||
# refetch request actually lands, this in-process commit (microseconds
|
||||
# away) has essentially always already happened.
|
||||
# B-80 (RND-35, DAT-60): the round's own pool address, now that its id exists —
|
||||
# still inside the caller's transaction, so no committed round ever lacks one.
|
||||
round_.pool_address = derive_pool_address(round_.id)
|
||||
broadcaster.publish()
|
||||
return round_
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- B-66: no fee_address means no round can open at all (the payout pays the
|
||||
30% commission to it, so it cannot even be built). Shown here because
|
||||
this is the one screen that can fix it. -->
|
||||
<div class="warning-banner hidden" id="admin-fee-address-warning">
|
||||
⚠️ Nessun fee address configurato: finché resta vuoto <strong>non si aprirà nessun round</strong>
|
||||
(il payout non sarebbe costruibile). Impostalo qui sotto e salva.
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="admin-fee-address">Fee address (dove finisce il 30% di ogni round)</label>
|
||||
@@ -76,6 +83,8 @@
|
||||
<input id="admin-round-cooldown" inputmode="numeric" placeholder="es. 30">
|
||||
<label for="admin-draw-animation">Durata animazione estrazione (secondi)</label>
|
||||
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
|
||||
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
|
||||
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
|
||||
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
|
||||
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
|
||||
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
|
||||
|
||||
@@ -141,10 +141,16 @@ async function adminLoadConfig() {
|
||||
try {
|
||||
const data = await callAdmin('GET', '/admin/config');
|
||||
document.getElementById('admin-fee-address').value = data.fee_address;
|
||||
// B-66: an empty fee address blocks every future round, so say so here rather
|
||||
// than leaving an empty field to be noticed.
|
||||
document
|
||||
.getElementById('admin-fee-address-warning')
|
||||
.classList.toggle('hidden', !!(data.fee_address || '').trim());
|
||||
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
|
||||
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
|
||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
|
||||
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
|
||||
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
|
||||
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
||||
renderMaintenanceState(data.paused);
|
||||
@@ -204,6 +210,7 @@ async function adminSave() {
|
||||
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
|
||||
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
||||
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
|
||||
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
|
||||
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
||||
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
||||
};
|
||||
|
||||
+60
-11
@@ -235,7 +235,18 @@ function renderChainStatusBar() {
|
||||
label.textContent = t(CHAIN_STATUS_KEYS[labelKey]);
|
||||
block.textContent = t('chain.block', { n: data.chain_tip_height != null ? '#' + data.chain_tip_height : '—' });
|
||||
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused);
|
||||
// Two separate reasons no round will open, and telling them apart matters to the
|
||||
// reader: a pause ends when the operator resumes, while an unconfigured instance
|
||||
// (B-66) won't produce a round at all until it's set up — "come back later" would
|
||||
// be a lie. `=== false` so an older server that doesn't send the field at all
|
||||
// can't flash the banner. A pause takes precedence: it's the deliberate action.
|
||||
const notConfigured = data.lottery_configured === false;
|
||||
const noRoundsComing = !!data.lottery_paused || notConfigured;
|
||||
document.getElementById('maintenance-banner').classList.toggle('hidden', !noRoundsComing);
|
||||
if (noRoundsComing) {
|
||||
document.getElementById('maintenance-banner-text').textContent =
|
||||
data.lottery_paused ? t('maintenance.banner') : t('maintenance.notConfigured');
|
||||
}
|
||||
}
|
||||
|
||||
// After a couple of consecutive failed polls (network blip, server restart,
|
||||
@@ -368,6 +379,49 @@ async function checkLastRoundResult() {
|
||||
|
||||
let lastJackpotValue = null;
|
||||
|
||||
// The round's own confirmed/in-flight split (B-65). The big numbers are the
|
||||
// confirmed ones — the players the draw will pick from and the pool the payout
|
||||
// will actually spend — because a jackpot advertised larger than the one paid out
|
||||
// is the kind of gap nobody forgives. What has been bet but hasn't confirmed yet
|
||||
// is shown next to them instead of being folded in, so the player who just bet
|
||||
// still sees their own bet immediately (the same reasoning as the amber
|
||||
// pending balance, see setBalanceDisplay).
|
||||
//
|
||||
// Kept out of the [data-i18n] mechanism on purpose: these come from server data,
|
||||
// so onLanguageChange() re-renders them through t() like every other dynamic bit.
|
||||
let lastRoundStats = null;
|
||||
|
||||
function renderRoundStats(data) {
|
||||
lastRoundStats = data;
|
||||
document.getElementById('round-players').textContent = data.participant_count;
|
||||
|
||||
const jackpotEl = document.getElementById('round-jackpot');
|
||||
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
|
||||
jackpotEl.textContent = formatPlm(data.jackpot_sats);
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
jackpotEl.classList.add('jackpot-bump');
|
||||
}
|
||||
lastJackpotValue = jackpotValue;
|
||||
|
||||
// pending_* are inclusive of the confirmed figures (like pending_balance_sats),
|
||||
// so what's shown alongside is the difference.
|
||||
const pendingPlayers = (data.pending_participant_count || 0) - data.participant_count;
|
||||
const pendingJackpotSats = (data.pending_jackpot_sats || 0) - data.jackpot_sats;
|
||||
const show = !!data.has_pending_bets && pendingPlayers > 0;
|
||||
|
||||
const playersPendingEl = document.getElementById('round-players-pending');
|
||||
playersPendingEl.textContent = show ? t('round.playersPending', { n: pendingPlayers }) : '';
|
||||
playersPendingEl.classList.toggle('hidden', !show);
|
||||
|
||||
const jackpotPendingEl = document.getElementById('round-jackpot-pending');
|
||||
jackpotPendingEl.textContent = show
|
||||
? t('round.jackpotPending', { amount: formatPlm(pendingJackpotSats) })
|
||||
: '';
|
||||
jackpotPendingEl.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
let timerHitZero = false;
|
||||
|
||||
function updateRoundTimer() {
|
||||
@@ -451,16 +505,7 @@ async function refreshRound() {
|
||||
: t('round.none');
|
||||
betAmountSats = data.bet_amount_sats;
|
||||
renderBetButton();
|
||||
document.getElementById('round-players').textContent = data.participant_count;
|
||||
const jackpotEl = document.getElementById('round-jackpot');
|
||||
const jackpotValue = data.jackpot_sats / SATS_PER_PLM;
|
||||
jackpotEl.textContent = formatPlm(data.jackpot_sats);
|
||||
if (lastJackpotValue !== null && jackpotValue !== lastJackpotValue) {
|
||||
jackpotEl.classList.remove('jackpot-bump');
|
||||
void jackpotEl.offsetWidth; // restart the animation
|
||||
jackpotEl.classList.add('jackpot-bump');
|
||||
}
|
||||
lastJackpotValue = jackpotValue;
|
||||
renderRoundStats(data);
|
||||
if (data.server_time) serverTimeOffsetMs = new Date(data.server_time) - new Date();
|
||||
roundCloseAt = data.closes_at ? new Date(data.closes_at) : null;
|
||||
updateRoundTimer();
|
||||
@@ -858,6 +903,10 @@ function connectRoundEvents() {
|
||||
function onLanguageChange() {
|
||||
renderBetButton();
|
||||
renderChainStatusBar(); // repaints from remembered state, without waiting for the next poll
|
||||
// Same reason, for the round's "+N in attesa" suffixes (B-65): repaint from what
|
||||
// was last received rather than leaving them in the old language until the
|
||||
// refreshRound() below happens to come back.
|
||||
if (lastRoundStats) renderRoundStats(lastRoundStats);
|
||||
if (token) {
|
||||
refreshRound();
|
||||
refreshMe();
|
||||
|
||||
@@ -41,6 +41,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Block {n}',
|
||||
'chain.connectionLost': 'Connection to server lost — retrying…',
|
||||
'maintenance.banner': 'Scheduled maintenance: the current round completes normally (winner included), but the next round will not open until maintenance ends.',
|
||||
'maintenance.notConfigured': 'This lottery is not ready to play yet: the operator still has to finish setting it up, and no round will open until then.',
|
||||
|
||||
'hero.lead': 'Deposit PLM, join the round with a fixed entry fee, and if your number is drawn you win the jackpot.',
|
||||
'hero.step1.title': '1. Deposit',
|
||||
@@ -63,6 +64,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Players',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} pending',
|
||||
'round.jackpotPending': '+{amount} pending',
|
||||
'round.status.open': 'open',
|
||||
'round.status.closing': 'closing',
|
||||
'round.status.drawing': 'drawing in progress',
|
||||
@@ -139,11 +142,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Not connected to the network, please try again shortly.',
|
||||
'error.no_round_open': 'No round is open right now, please try again shortly.',
|
||||
'error.round_closing': 'The current round is closing, please try again shortly.',
|
||||
'error.round_full': 'This round has reached its maximum of {max_participants} players — wait for the next one, it opens shortly.',
|
||||
'error.already_betting': 'You already have an active bet in the current round.',
|
||||
'error.insufficient_balance': 'Insufficient balance.',
|
||||
'error.balance_leaves_no_change': 'Your balance is too close to the bet amount: {required_extra_plm} PLM more are needed so the transaction keeps a change output and can be fee-bumped if the network is slow.',
|
||||
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
|
||||
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
|
||||
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
|
||||
'error.round_not_found': 'Round not found.',
|
||||
'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.',
|
||||
'error.current_password_incorrect': 'The current password is incorrect.',
|
||||
'error.password_too_short': 'The new password must be at least {minimum} characters.',
|
||||
@@ -154,9 +160,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': 'Invalid request, please check the entered data.',
|
||||
'error.invalid_amount': 'Enter an amount greater than zero.',
|
||||
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
|
||||
'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.',
|
||||
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
|
||||
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
|
||||
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
|
||||
'error.withdrawal_to_platform_address': 'That is an address of this platform — withdraw to an external wallet.',
|
||||
'error.internal_error': 'Unexpected server error. Please try again shortly.',
|
||||
'error.guide_unavailable': 'The guide is not available right now.',
|
||||
'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.',
|
||||
@@ -204,6 +212,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Blocco {n}',
|
||||
'chain.connectionLost': 'Connessione al server persa — riprovo…',
|
||||
'maintenance.banner': 'Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.',
|
||||
'maintenance.notConfigured': 'Questa lotteria non è ancora pronta: l\'operatore deve completare la configurazione, e fino a quel momento non si aprirà nessun round.',
|
||||
|
||||
'hero.lead': 'Deposita PLM, entra nel round con una quota fissa, e se viene estratto il tuo numero vinci il montepremi.',
|
||||
'hero.step1.title': '1. Deposita',
|
||||
@@ -226,6 +235,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Giocatori',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} in attesa',
|
||||
'round.jackpotPending': '+{amount} in attesa',
|
||||
'round.status.open': 'aperto',
|
||||
'round.status.closing': 'in chiusura',
|
||||
'round.status.drawing': 'estrazione in corso',
|
||||
@@ -299,11 +310,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Nessuna connessione alla rete, riprova tra poco.',
|
||||
'error.no_round_open': 'Nessun round aperto in questo momento, riprova tra poco.',
|
||||
'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.',
|
||||
'error.round_full': 'Questo round ha raggiunto il massimo di {max_participants} giocatori — aspetta il prossimo, si apre tra poco.',
|
||||
'error.already_betting': 'Hai già una bet attiva nel round corrente.',
|
||||
'error.insufficient_balance': 'Saldo insufficiente.',
|
||||
'error.balance_leaves_no_change': 'Il tuo saldo è troppo vicino all\'importo della giocata: servono {required_extra_plm} PLM in più perché la transazione mantenga un resto e possa essere rilanciata con fee più alta se la rete è lenta.',
|
||||
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
|
||||
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
|
||||
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
|
||||
'error.round_not_found': 'Round non trovato.',
|
||||
'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.",
|
||||
'error.current_password_incorrect': 'La password attuale non è corretta.',
|
||||
'error.password_too_short': 'La nuova password deve avere almeno {minimum} caratteri.',
|
||||
@@ -314,9 +328,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': 'Richiesta non valida, controlla i dati inseriti.',
|
||||
'error.invalid_amount': 'Inserisci un importo maggiore di zero.',
|
||||
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
|
||||
'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.',
|
||||
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
|
||||
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
|
||||
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
|
||||
'error.withdrawal_to_platform_address': 'Questo è un indirizzo della piattaforma — preleva verso un wallet esterno.',
|
||||
'error.internal_error': 'Errore inatteso del server. Riprova tra poco.',
|
||||
'error.guide_unavailable': 'La guida non è disponibile in questo momento.',
|
||||
'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.',
|
||||
@@ -364,6 +380,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Bloque {n}',
|
||||
'chain.connectionLost': 'Conexión con el servidor perdida — reintentando…',
|
||||
'maintenance.banner': 'Mantenimiento programado: la ronda actual se completa con normalidad (ganador incluido), pero la siguiente ronda no se abrirá hasta que finalice el mantenimiento.',
|
||||
'maintenance.notConfigured': 'Esta lotería todavía no está lista: el operador tiene que terminar de configurarla y, hasta entonces, no se abrirá ninguna ronda.',
|
||||
|
||||
'hero.lead': 'Deposita PLM, únete a la ronda con una cuota fija de entrada, y si sale tu número ganas el bote.',
|
||||
'hero.step1.title': '1. Deposita',
|
||||
@@ -386,6 +403,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Jugadores',
|
||||
'round.jackpot': 'Bote',
|
||||
'round.playersPending': '+{n} pendientes',
|
||||
'round.jackpotPending': '+{amount} pendientes',
|
||||
'round.status.open': 'abierta',
|
||||
'round.status.closing': 'cerrando',
|
||||
'round.status.drawing': 'sorteo en curso',
|
||||
@@ -459,11 +478,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Sin conexión con la red, inténtalo de nuevo en un momento.',
|
||||
'error.no_round_open': 'No hay ninguna ronda abierta ahora mismo, inténtalo de nuevo en un momento.',
|
||||
'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.',
|
||||
'error.round_full': 'Esta ronda ha alcanzado su máximo de {max_participants} jugadores: espera la siguiente, se abre en breve.',
|
||||
'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.',
|
||||
'error.insufficient_balance': 'Saldo insuficiente.',
|
||||
'error.balance_leaves_no_change': 'Tu saldo está demasiado cerca del importe de la apuesta: hacen falta {required_extra_plm} PLM más para que la transacción conserve un cambio y pueda relanzarse con más comisión si la red va lenta.',
|
||||
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
|
||||
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
|
||||
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
|
||||
'error.round_not_found': 'Ronda no encontrada.',
|
||||
'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.',
|
||||
'error.current_password_incorrect': 'La contraseña actual no es correcta.',
|
||||
'error.password_too_short': 'La nueva contraseña debe tener al menos {minimum} caracteres.',
|
||||
@@ -474,9 +496,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': 'Solicitud no válida, revisa los datos introducidos.',
|
||||
'error.invalid_amount': 'Introduce un importe mayor que cero.',
|
||||
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
|
||||
'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.',
|
||||
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
|
||||
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
|
||||
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
|
||||
'error.withdrawal_to_platform_address': 'Esa es una dirección de la propia plataforma — retira a una cartera externa.',
|
||||
'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.',
|
||||
'error.guide_unavailable': 'La guía no está disponible en este momento.',
|
||||
'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.',
|
||||
@@ -524,6 +548,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Bloc {n}',
|
||||
'chain.connectionLost': 'Connexion au serveur perdue — nouvelle tentative…',
|
||||
'maintenance.banner': "Maintenance programmée : le round en cours se termine normalement (gagnant inclus), mais le round suivant ne s'ouvrira qu'une fois la maintenance terminée.",
|
||||
'maintenance.notConfigured': "Cette loterie n'est pas encore prête : l'opérateur doit terminer la configuration, et aucun round ne s'ouvrira avant.",
|
||||
|
||||
'hero.lead': 'Déposez des PLM, rejoignez le round avec une mise fixe, et si votre numéro est tiré vous remportez le jackpot.',
|
||||
'hero.step1.title': '1. Déposez',
|
||||
@@ -546,6 +571,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Joueurs',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} en attente',
|
||||
'round.jackpotPending': '+{amount} en attente',
|
||||
'round.status.open': 'ouvert',
|
||||
'round.status.closing': 'en fermeture',
|
||||
'round.status.drawing': 'tirage en cours',
|
||||
@@ -619,11 +646,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Pas de connexion au réseau, réessayez dans un instant.',
|
||||
'error.no_round_open': "Aucun round n'est ouvert pour le moment, réessayez dans un instant.",
|
||||
'error.round_closing': 'Le round en cours est en train de se fermer, réessayez dans un instant.',
|
||||
'error.round_full': 'Ce tour a atteint son maximum de {max_participants} joueurs — attendez le prochain, il ouvre dans un instant.',
|
||||
'error.already_betting': 'Vous avez déjà une mise active dans le round en cours.',
|
||||
'error.insufficient_balance': 'Solde insuffisant.',
|
||||
'error.balance_leaves_no_change': 'Votre solde est trop proche du montant de la mise : il faut {required_extra_plm} PLM de plus pour que la transaction garde une monnaie de rendu et puisse être relancée avec des frais plus élevés si le réseau est lent.',
|
||||
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
|
||||
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
|
||||
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
|
||||
'error.round_not_found': 'Manche introuvable.',
|
||||
'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.',
|
||||
'error.current_password_incorrect': "Le mot de passe actuel n'est pas correct.",
|
||||
'error.password_too_short': 'Le nouveau mot de passe doit comporter au moins {minimum} caractères.',
|
||||
@@ -634,9 +664,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': 'Requête invalide, vérifiez les données saisies.',
|
||||
'error.invalid_amount': 'Saisissez un montant supérieur à zéro.',
|
||||
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
|
||||
'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.',
|
||||
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
|
||||
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
|
||||
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
|
||||
'error.withdrawal_to_platform_address': "C'est une adresse de la plateforme elle-même — retirez vers un portefeuille externe.",
|
||||
'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.',
|
||||
'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.",
|
||||
'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.',
|
||||
@@ -684,6 +716,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Block {n}',
|
||||
'chain.connectionLost': 'Verbindung zum Server verloren — erneuter Versuch…',
|
||||
'maintenance.banner': 'Geplante Wartung: Die laufende Runde wird regulär abgeschlossen (Gewinner inklusive), aber die nächste Runde öffnet erst, wenn die Wartung beendet ist.',
|
||||
'maintenance.notConfigured': 'Diese Lotterie ist noch nicht spielbereit: der Betreiber muss die Einrichtung abschließen, bis dahin öffnet keine Runde.',
|
||||
|
||||
'hero.lead': 'Zahle PLM ein, nimm mit einem festen Einsatz an der Runde teil, und wenn deine Zahl gezogen wird, gewinnst du den Jackpot.',
|
||||
'hero.step1.title': '1. Einzahlen',
|
||||
@@ -706,6 +739,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Spieler',
|
||||
'round.jackpot': 'Jackpot',
|
||||
'round.playersPending': '+{n} ausstehend',
|
||||
'round.jackpotPending': '+{amount} ausstehend',
|
||||
'round.status.open': 'offen',
|
||||
'round.status.closing': 'wird geschlossen',
|
||||
'round.status.drawing': 'Ziehung läuft',
|
||||
@@ -779,11 +814,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Keine Verbindung zum Netzwerk, bitte versuche es gleich erneut.',
|
||||
'error.no_round_open': 'Derzeit ist keine Runde offen, bitte versuche es gleich erneut.',
|
||||
'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.',
|
||||
'error.round_full': 'Diese Runde hat ihr Maximum von {max_participants} Spielern erreicht — warten Sie auf die nächste, sie beginnt in Kürze.',
|
||||
'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.',
|
||||
'error.insufficient_balance': 'Nicht genügend Guthaben.',
|
||||
'error.balance_leaves_no_change': 'Ihr Guthaben liegt zu nah am Einsatzbetrag: Es werden {required_extra_plm} PLM mehr benötigt, damit die Transaktion einen Wechselgeld-Ausgang behält und bei langsamem Netz mit höherer Gebühr neu gesendet werden kann.',
|
||||
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
|
||||
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
|
||||
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
|
||||
'error.round_not_found': 'Runde nicht gefunden.',
|
||||
'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.',
|
||||
'error.current_password_incorrect': 'Das aktuelle Passwort ist nicht korrekt.',
|
||||
'error.password_too_short': 'Das neue Passwort muss mindestens {minimum} Zeichen lang sein.',
|
||||
@@ -794,9 +832,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': 'Ungültige Anfrage, bitte überprüfe die eingegebenen Daten.',
|
||||
'error.invalid_amount': 'Gib einen Betrag größer als null ein.',
|
||||
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
|
||||
'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.',
|
||||
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
|
||||
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
|
||||
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
|
||||
'error.withdrawal_to_platform_address': 'Das ist eine Adresse der Plattform selbst — zahle auf eine externe Wallet aus.',
|
||||
'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.',
|
||||
'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.',
|
||||
'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.',
|
||||
@@ -844,6 +884,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': 'Блок {n}',
|
||||
'chain.connectionLost': 'Соединение с сервером потеряно — повторная попытка…',
|
||||
'maintenance.banner': 'Запланировано техобслуживание: текущий раунд завершится в обычном порядке (включая победителя), но следующий раунд не откроется до окончания техобслуживания.',
|
||||
'maintenance.notConfigured': 'Эта лотерея пока не готова: оператору нужно завершить настройку, до этого ни один раунд не откроется.',
|
||||
|
||||
'hero.lead': 'Внесите PLM, вступите в раунд с фиксированной ставкой, и если выпадет ваш номер — вы выиграете джекпот.',
|
||||
'hero.step1.title': '1. Внесите депозит',
|
||||
@@ -866,6 +907,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': 'Игроки',
|
||||
'round.jackpot': 'Джекпот',
|
||||
'round.playersPending': '+{n} в ожидании',
|
||||
'round.jackpotPending': '+{amount} в ожидании',
|
||||
'round.status.open': 'открыт',
|
||||
'round.status.closing': 'закрывается',
|
||||
'round.status.drawing': 'идёт розыгрыш',
|
||||
@@ -939,11 +982,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': 'Нет соединения с сетью, повторите попытку чуть позже.',
|
||||
'error.no_round_open': 'Сейчас нет открытого раунда, повторите попытку чуть позже.',
|
||||
'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.',
|
||||
'error.round_full': 'В этом раунде достигнут максимум участников ({max_participants}) — дождитесь следующего, он начнётся совсем скоро.',
|
||||
'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.',
|
||||
'error.insufficient_balance': 'Недостаточно средств.',
|
||||
'error.balance_leaves_no_change': 'Ваш баланс слишком близок к сумме ставки: нужно ещё {required_extra_plm} PLM, чтобы в транзакции остался выход сдачи и её можно было переотправить с более высокой комиссией, если сеть работает медленно.',
|
||||
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
|
||||
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
|
||||
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
|
||||
'error.round_not_found': 'Раунд не найден.',
|
||||
'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.',
|
||||
'error.current_password_incorrect': 'Текущий пароль указан неверно.',
|
||||
'error.password_too_short': 'Новый пароль должен содержать не менее {minimum} символов.',
|
||||
@@ -954,9 +1000,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': 'Некорректный запрос, проверьте введённые данные.',
|
||||
'error.invalid_amount': 'Введите сумму больше нуля.',
|
||||
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
|
||||
'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.',
|
||||
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
|
||||
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
|
||||
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
|
||||
'error.withdrawal_to_platform_address': 'Это адрес самой платформы — выводите на внешний кошелёк.',
|
||||
'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.',
|
||||
'error.guide_unavailable': 'Руководство сейчас недоступно.',
|
||||
'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.',
|
||||
@@ -1004,6 +1052,7 @@ const TRANSLATIONS = {
|
||||
'chain.block': '区块 {n}',
|
||||
'chain.connectionLost': '与服务器的连接已断开——正在重试…',
|
||||
'maintenance.banner': '计划维护:当前回合将照常完成(包括中奖者),但下一回合要等维护结束后才会开启。',
|
||||
'maintenance.notConfigured': '本彩票尚未就绪:运营方还需完成配置,在此之前不会开启任何回合。',
|
||||
|
||||
'hero.lead': '存入 PLM,以固定金额参与本回合,若抽中你的号码即可赢得奖池。',
|
||||
'hero.step1.title': '1. 存款',
|
||||
@@ -1026,6 +1075,8 @@ const TRANSLATIONS = {
|
||||
|
||||
'round.players': '参与人数',
|
||||
'round.jackpot': '奖池',
|
||||
'round.playersPending': '+{n} 待确认',
|
||||
'round.jackpotPending': '+{amount} 待确认',
|
||||
'round.status.open': '进行中',
|
||||
'round.status.closing': '即将结束',
|
||||
'round.status.drawing': '正在开奖',
|
||||
@@ -1099,11 +1150,14 @@ const TRANSLATIONS = {
|
||||
'error.network_unavailable': '未连接到网络,请稍后重试。',
|
||||
'error.no_round_open': '当前没有开放的回合,请稍后重试。',
|
||||
'error.round_closing': '当前回合正在结束,请稍后重试。',
|
||||
'error.round_full': '本轮已达到 {max_participants} 名玩家的上限 —— 请等待下一轮,很快就会开始。',
|
||||
'error.already_betting': '你在当前回合已有一笔有效下注。',
|
||||
'error.insufficient_balance': '余额不足。',
|
||||
'error.balance_leaves_no_change': '您的余额与投注金额过于接近:还需要 {required_extra_plm} PLM,交易才能保留找零输出,并在网络拥堵时提高手续费重新广播。',
|
||||
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
|
||||
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
|
||||
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
|
||||
'error.round_not_found': '未找到该轮次。',
|
||||
'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。',
|
||||
'error.current_password_incorrect': '当前密码不正确。',
|
||||
'error.password_too_short': '新密码长度至少需要 {minimum} 个字符。',
|
||||
@@ -1114,9 +1168,11 @@ const TRANSLATIONS = {
|
||||
'error.invalid_request': '请求无效,请检查填写的内容。',
|
||||
'error.invalid_amount': '请输入大于零的金额。',
|
||||
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
|
||||
'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。',
|
||||
'error.amount_below_dust_limit': '金额过小,无法发送。',
|
||||
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
|
||||
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
|
||||
'error.withdrawal_to_platform_address': '这是平台自己的地址 — 请提现到外部钱包。',
|
||||
'error.internal_error': '服务器发生意外错误,请稍后重试。',
|
||||
'error.guide_unavailable': '指南当前不可用。',
|
||||
'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。',
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
|
||||
<div class="maintenance-banner hidden" id="maintenance-banner">
|
||||
<span>⚠️</span>
|
||||
<span data-i18n="maintenance.banner">Manutenzione in programma: il round in corso viene completato regolarmente (vincitore incluso), ma il round successivo non si aprirà finché la manutenzione non sarà terminata.</span>
|
||||
<!-- Filled by renderChainStatusBar(): the text depends on *why* no round will
|
||||
open — a deliberate pause, or an instance the operator hasn't finished
|
||||
configuring (B-66) — so it renders through t() and carries no data-i18n. -->
|
||||
<span id="maintenance-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<section id="landing-hero" class="hero">
|
||||
@@ -150,11 +153,15 @@
|
||||
<div class="row-between" style="margin-top:10px" id="round-stats-row">
|
||||
<div>
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.players">Giocatori</div>
|
||||
<span class="mono" id="round-players">—</span>
|
||||
<!-- The two -pending spans are filled from server data by renderRoundStats()
|
||||
(B-65), so they carry no data-i18n: an element belongs to one
|
||||
translation mechanism or the other, never both. -->
|
||||
<span class="mono" id="round-players">—</span> <span class="pending-suffix hidden" id="round-players-pending"></span>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="hint" style="margin-bottom:2px" data-i18n="round.jackpot">Jackpot</div>
|
||||
<span class="mono" id="round-jackpot">—</span> <span class="balance-unit">PLM</span>
|
||||
<span class="pending-suffix hidden" id="round-jackpot-pending"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -215,6 +215,12 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
||||
.balance-confirmed { color: var(--color-success); }
|
||||
.balance-pending { color: var(--color-primary); }
|
||||
|
||||
/* The in-flight part of the round's own figures (B-65): the players/jackpot next
|
||||
to it are the confirmed ones the draw and the payout will actually use, and this
|
||||
is what has been bet but hasn't confirmed yet. Same amber as .balance-pending,
|
||||
for the same "not settled" meaning. */
|
||||
.pending-suffix { color: var(--color-primary); font-size: 0.8rem; font-weight: 600; }
|
||||
|
||||
.icon { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.dash-panel { display: none; }
|
||||
|
||||
+123
-24
@@ -6,13 +6,24 @@ 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 import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import (
|
||||
PendingTransaction,
|
||||
PendingTransactionTxid,
|
||||
Round,
|
||||
RoundParticipant,
|
||||
User,
|
||||
UtxoEvent,
|
||||
Withdrawal,
|
||||
)
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.wallet.hd import derive_pool_key, derive_user_key
|
||||
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
||||
from app.tx.versions import record_version
|
||||
from app.wallet.hd import derive_round_pool_key, derive_user_key
|
||||
from app.wallet.plm_network import PLM_MAINNET
|
||||
from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize
|
||||
|
||||
@@ -48,12 +59,18 @@ def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int
|
||||
return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds)
|
||||
|
||||
|
||||
async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple:
|
||||
async def _signing_context(
|
||||
session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None
|
||||
) -> 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."""
|
||||
payout (the round's own pool key, B-80). All our builders only ever spend one
|
||||
address's UTXOs per tx."""
|
||||
if kind == "payout":
|
||||
key = derive_pool_key()
|
||||
round_ = await session.get(Round, round_id) if round_id is not None else None
|
||||
if round_ is None or not round_.pool_address:
|
||||
raise RbfError(f"payout of round {round_id}: the round has no pool address")
|
||||
key = derive_round_pool_key(round_.id, round_.pool_address)
|
||||
else:
|
||||
user = await session.get(User, user_id)
|
||||
key = derive_user_key(user.derivation_index)
|
||||
@@ -86,28 +103,42 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None:
|
||||
|
||||
|
||||
async def bump_fee(
|
||||
session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int
|
||||
session_factory: async_sessionmaker,
|
||||
client: ElectrumClient,
|
||||
pending_id: int,
|
||||
network: TransactionNetwork | None = None,
|
||||
) -> str | None:
|
||||
"""Rebuild pending_transaction `pending_id`'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, or None if there was nothing
|
||||
to do (the row is gone or already left "pending" — a normal race with
|
||||
confirmation, not an error).
|
||||
confirmation, not an error, or a conflict recorded below).
|
||||
|
||||
Three phases, so no DB session is held across the network calls this needs
|
||||
Four phases, so no DB session is held across the network calls this needs
|
||||
(one get_transaction per input, then a broadcast) — the same shape used
|
||||
elsewhere for exactly this reason (B-18, rounds/scheduler.py:_trigger_payout;
|
||||
B-31, electrum/listener.py:refresh_user) and now here too (B-40): read what's
|
||||
needed and close the session, do the chain work, then reopen to persist.
|
||||
|
||||
B-77 (TX-12, TX-13, DAT-34): the replacement is **written before it is broadcast**.
|
||||
It used to be sent in the chain phase and recorded afterwards, unconditionally, so
|
||||
a crash or a `database is locked` in between left a transaction on the network
|
||||
that the database did not know, and an abandon or a confirmation that happened in
|
||||
the meantime was silently overwritten. Now the signed replacement is committed as
|
||||
a *candidate* version (pending_transaction_txids) first; a candidate that is never
|
||||
broadcast is harmless, and one that is — and then confirms — is recognised like any
|
||||
other version. After the broadcast the row is switched over with a conditional
|
||||
UPDATE (status still pending, current_txid still the one replaced); zero rows
|
||||
matched is a conflict: the row is left alone, the candidate stays recorded, and
|
||||
the reconciler resolves it.
|
||||
|
||||
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. Also raises
|
||||
RbfError, rather than bumping, once the row is already at MAX_FEE_RATE_SAT_VB
|
||||
(B-32) — the reconciler abandons it if it never confirms (B-27), instead of
|
||||
this retrying an ever-higher fee forever.
|
||||
(B-32), and when the network explicitly refuses the replacement (TX-24).
|
||||
"""
|
||||
# --- Phase 1: read what's needed, close the session before any network call ---
|
||||
async with session_factory() as session:
|
||||
@@ -124,9 +155,10 @@ async def bump_fee(
|
||||
kind = pending.kind
|
||||
current_fee_rate = pending.fee_rate_sat_vb
|
||||
raw_tx_hex = pending.raw_tx_hex
|
||||
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id)
|
||||
old_txid = pending.current_txid
|
||||
signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id, pending.round_id)
|
||||
|
||||
# --- Phase 2: chain reads, signing, and the broadcast — no DB session open ----
|
||||
# --- Phase 2: chain reads and signing — no DB session open, nothing sent yet ---
|
||||
old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex))
|
||||
input_amounts = [await _prevout_amount(client, vin) for vin in old_tx.vin]
|
||||
total_in = sum(input_amounts)
|
||||
@@ -171,26 +203,69 @@ async def bump_fee(
|
||||
|
||||
raw_hex = final_tx.serialize().hex()
|
||||
new_txid = final_tx.txid().hex()
|
||||
await client.broadcast(raw_hex)
|
||||
|
||||
# --- Phase 3: persist the outcome ----------------------------------------------
|
||||
# --- Phase 3: record the replacement as a candidate, *then* broadcast ----------
|
||||
async with session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
old_txid = pending.current_txid
|
||||
pending.replaced_by_txid = old_txid # points backwards: what current_txid replaced
|
||||
pending.current_txid = new_txid
|
||||
pending.raw_tx_hex = raw_hex
|
||||
if pending is None or pending.status != "pending" or pending.current_txid != old_txid:
|
||||
logger.info("pending_transaction %s changed while its bump was being built; skipping", pending_id)
|
||||
return None
|
||||
await record_version(session, pending, new_txid, raw_hex) # B-77 (TX-12, DAT-71)
|
||||
await session.commit()
|
||||
|
||||
verdict = await broadcast_with_verdict(client, network, raw_hex, new_txid) # B-76: also fans out
|
||||
if verdict.outcome is BroadcastOutcome.REFUSED:
|
||||
# The network explicitly refused the replacement and no server knows it: the row
|
||||
# is unchanged and keeps the version that is on the network (TX-24). The
|
||||
# candidate stays recorded — it was never relayed, so nobody will confirm it.
|
||||
raise RbfError(f"pending_transaction {pending_id}: the network refused the replacement: {verdict.reason}")
|
||||
# ACCEPTED, or UNCERTAIN: the replacement may well be on the network, so the row
|
||||
# follows it (TX-12); the old version stays recorded and is still recognised should
|
||||
# it be the one that confirms.
|
||||
|
||||
# --- Phase 4: switch the row over, conditionally --------------------------------
|
||||
async with session_factory() as session:
|
||||
await begin_immediate(session) # B-88
|
||||
# The *actual* resulting rate, not target_fee_rate: when the BIP125-minimum
|
||||
# floor above raised fee_delta past the naive target, the tx now pays more
|
||||
# than target_fee_rate implied. Recording the true rate keeps the next bump's
|
||||
# arithmetic honest instead of drifting from what's really being paid.
|
||||
pending.fee_rate_sat_vb = (old_fee + fee_delta) // vsize
|
||||
pending.attempt_count += 1
|
||||
# last_broadcast_at, not broadcast_at (B-27): broadcast_at must stay the *first*
|
||||
# broadcast, since reconcile.py's abandon-after-N-hours grace period is measured
|
||||
# from it — overwriting it here used to reset that clock on every bump, so a
|
||||
# repeatedly-bumped-but-never-mined tx was never abandoned.
|
||||
pending.last_broadcast_at = datetime.now(timezone.utc)
|
||||
result = await session.execute(
|
||||
update(PendingTransaction)
|
||||
.where(
|
||||
PendingTransaction.id == pending_id,
|
||||
PendingTransaction.status == "pending",
|
||||
PendingTransaction.current_txid == old_txid,
|
||||
)
|
||||
.values(
|
||||
replaced_by_txid=old_txid, # points backwards: what current_txid replaced
|
||||
current_txid=new_txid,
|
||||
raw_tx_hex=raw_hex,
|
||||
fee_rate_sat_vb=(old_fee + fee_delta) // vsize,
|
||||
attempt_count=PendingTransaction.attempt_count + 1,
|
||||
last_broadcast_at=datetime.now(timezone.utc),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
# Confirmed, abandoned or already re-bumped while we were broadcasting. The
|
||||
# row is not overwritten; the candidate is still recorded, and confirmation
|
||||
# and reconciliation look for it like any other version (TX-13).
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"bump of pending_transaction %s: the row changed during the broadcast (replaced %s by %s); "
|
||||
"left to the reconciler",
|
||||
pending_id,
|
||||
old_txid,
|
||||
new_txid,
|
||||
)
|
||||
return None
|
||||
pending = await session.get(PendingTransaction, pending_id)
|
||||
await _retarget_txid_references(session, pending, old_txid, new_txid)
|
||||
await session.commit()
|
||||
|
||||
@@ -198,6 +273,29 @@ async def bump_fee(
|
||||
return new_txid
|
||||
|
||||
|
||||
async def adopt_version(session: AsyncSession, pending: PendingTransaction, txid: str) -> None:
|
||||
"""The network confirmed (or holds) a version of this row other than the current one
|
||||
— an earlier version of a bumped transaction, or a candidate whose switch-over never
|
||||
happened (B-77, TX-19, DAT-14). `current_txid` and `raw_tx_hex` become that
|
||||
version's, and the txid is propagated everywhere it is recorded, exactly as after a
|
||||
bump; without it the inputs of a replaced transaction would no longer be
|
||||
recognised as the row's (DAT-13)."""
|
||||
if txid == pending.current_txid:
|
||||
return
|
||||
version = await session.scalar(
|
||||
select(PendingTransactionTxid).where(
|
||||
PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == pending.id
|
||||
)
|
||||
)
|
||||
old_txid = pending.current_txid
|
||||
pending.replaced_by_txid = old_txid
|
||||
pending.current_txid = txid
|
||||
if version is not None and version.raw_hex:
|
||||
pending.raw_tx_hex = version.raw_hex # empty only for a migration-reconstructed txid
|
||||
await _retarget_txid_references(session, pending, old_txid, txid)
|
||||
logger.warning("pending_transaction %s: version %s found on the network, replacing %s", pending.id, txid, old_txid)
|
||||
|
||||
|
||||
async def _retarget_txid_references(
|
||||
session: AsyncSession, pending: PendingTransaction, old_txid: str, new_txid: str
|
||||
) -> None:
|
||||
@@ -239,9 +337,10 @@ async def _retarget_txid_references(
|
||||
|
||||
|
||||
class RbfBumper:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client):
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client, network: TransactionNetwork | None = None):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
self._network = network # B-76/B-77: verifies and fans out a replacement
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
@@ -266,7 +365,7 @@ class RbfBumper:
|
||||
|
||||
for pending_id in due_ids:
|
||||
try:
|
||||
await bump_fee(self._session_factory, client, pending_id)
|
||||
await bump_fee(self._session_factory, client, pending_id, network=self._network)
|
||||
except RbfError:
|
||||
logger.exception("could not bump pending_transaction %s", pending_id)
|
||||
except Exception:
|
||||
|
||||
+57
-10
@@ -1,15 +1,19 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Protocol
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import PendingTransaction
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.broadcast import adopt_version
|
||||
from app.tx.pending_address import own_address_for
|
||||
from app.tx.versions import txids_by_row
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,7 +30,19 @@ def register_handler(kind: str, handler: ConfirmationHandler) -> None:
|
||||
_handlers[kind] = handler
|
||||
|
||||
|
||||
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
||||
class ConfirmationProver(Protocol):
|
||||
"""The part of ElectrumListener a confirmation needs (B-83): the quorum's verdict on
|
||||
a merkle proof of `txid` at `height`."""
|
||||
|
||||
async def confirmation_proven(self, txid: str, height: int) -> bool: ...
|
||||
|
||||
|
||||
async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient, network: ConfirmationProver | None) -> int:
|
||||
"""One pass over the `pending` rows. The active server's history only nominates a
|
||||
candidate: a row is confirmed when the quorum proves it with a merkle proof
|
||||
(B-83, TX-22). With no `network` to ask nothing can be confirmed — the active
|
||||
server's word alone is exactly what a hostile one would use to declare a payout that
|
||||
was never broadcast confirmed."""
|
||||
async with session_factory() as session:
|
||||
# Plain columns, not entities: nothing then outlives the session, so this
|
||||
# can't break if expire_on_commit is ever turned on (B-21).
|
||||
@@ -37,24 +53,28 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
|
||||
PendingTransaction.current_txid,
|
||||
PendingTransaction.kind,
|
||||
PendingTransaction.user_id,
|
||||
PendingTransaction.round_id,
|
||||
).where(PendingTransaction.status == "pending")
|
||||
)
|
||||
).all()
|
||||
|
||||
# Resolved once per candidate while the session is still open, and cached
|
||||
# by scripthash below — every "payout" row shares the same pool address,
|
||||
# so this also avoids asking the server the same history twice per tick.
|
||||
# by scripthash below — the payout rows of one round share its pool
|
||||
# address (B-80), so this also avoids asking the server the same history twice.
|
||||
scripthash_by_id: dict[int, str] = {}
|
||||
for pending_id, _txid, kind, user_id in candidates:
|
||||
# B-77 (TX-06, TX-19): every txid a row ever had, not only the current one — an
|
||||
# earlier version of a bumped transaction may be the one that confirms.
|
||||
txids_by_id = await txids_by_row(session, {pending_id: txid for pending_id, txid, *_ in candidates})
|
||||
for pending_id, _txid, kind, user_id, round_id in candidates:
|
||||
try:
|
||||
address = await own_address_for(session, kind, user_id)
|
||||
address = await own_address_for(session, kind, user_id, round_id)
|
||||
scripthash_by_id[pending_id] = address_to_scripthash(address)
|
||||
except Exception:
|
||||
logger.exception("could not derive the address for pending_transaction %s", pending_id)
|
||||
|
||||
confirmed = 0
|
||||
history_cache: dict[str, list[dict]] = {}
|
||||
for pending_id, txid, kind, _user_id in candidates:
|
||||
for pending_id, txid, kind, _user_id, _round_id in candidates:
|
||||
scripthash = scripthash_by_id.get(pending_id)
|
||||
if scripthash is None:
|
||||
continue # address derivation failed above; already logged
|
||||
@@ -72,17 +92,38 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
|
||||
logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True)
|
||||
continue
|
||||
|
||||
entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None)
|
||||
known_txids = txids_by_id[pending_id]
|
||||
# height > 0 means confirmed at that height; 0 or absent means still in
|
||||
# the mempool (or the server doesn't know this txid at all yet) — either
|
||||
# way, not confirmed, so keep waiting.
|
||||
if entry is None or entry.get("height", 0) <= 0:
|
||||
entry = next(
|
||||
(e for e in history_cache[scripthash] if e.get("tx_hash") in known_txids and e.get("height", 0) > 0),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
# B-83 (TX-22, ELC-26): the height is a claim of the active server. The quorum has
|
||||
# to return a valid merkle proof of the txid against that block's header; without
|
||||
# one the row stays pending and is looked at again on the next pass.
|
||||
if network is None or not await network.confirmation_proven(entry["tx_hash"], entry["height"]):
|
||||
logger.warning(
|
||||
"pending_transaction %s: %s reported at height %s but the quorum did not prove it — still pending",
|
||||
pending_id,
|
||||
entry["tx_hash"],
|
||||
entry["height"],
|
||||
)
|
||||
continue
|
||||
|
||||
async with session_factory() as session:
|
||||
await begin_immediate(session) # B-88: read the row and write its outcome under one lock
|
||||
row = await session.get(PendingTransaction, pending_id)
|
||||
if row is None or row.status != "pending":
|
||||
continue
|
||||
if entry["tx_hash"] != row.current_txid:
|
||||
# The confirmed version is not the current one (B-77, DAT-14): realign
|
||||
# the row and everything that recorded its txid *before* the handler runs.
|
||||
await adopt_version(session, row, entry["tx_hash"])
|
||||
row.status = "confirmed"
|
||||
handler = _handlers.get(kind)
|
||||
if handler is not None:
|
||||
@@ -95,16 +136,22 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient)
|
||||
|
||||
|
||||
class ConfirmationPoller:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker,
|
||||
get_client: Callable[[], ElectrumClient | None],
|
||||
network: ConfirmationProver | None = None,
|
||||
):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
self._network = network # the ElectrumListener, which holds the headers and asks the quorum
|
||||
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
client = self._get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
await poll_once(self._session_factory, client)
|
||||
await poll_once(self._session_factory, client, self._network)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""What a broadcast attempt amounted to (B-76, TX-04, TX-20, TX-21).
|
||||
|
||||
Money may only be released on proof that a transaction never left. The text of an
|
||||
Electrum error cannot give that proof (servers word "already in the mempool" in many
|
||||
ways and there is no standard code), and a timeout or a dropped connection says
|
||||
nothing about whether the server acted before it went quiet. So a broadcast ends in
|
||||
one of three ways, and only one of them frees coins:
|
||||
|
||||
ACCEPTED the server answered with the txid, or refused but at least one server of
|
||||
the network (the active one or any other) returns the transaction;
|
||||
REFUSED the server answered with an explicit JSON-RPC error *and* the active
|
||||
server plus the quorum minimum of the others all say they do not know the
|
||||
transaction;
|
||||
UNCERTAIN anything else — a timeout, a dropped connection, a write error, a
|
||||
refusal whose verification could not get enough answers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import NamedTuple, Protocol
|
||||
|
||||
from app.electrum.client import ElectrumRpcError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Messages by which a node says it already has the transaction. Recognising one skips
|
||||
# the verification below; *not* recognising a message never releases anything, it only
|
||||
# costs one more round of questions (TX-04).
|
||||
_ALREADY_KNOWN_MARKERS = (
|
||||
"already known",
|
||||
"already in mempool",
|
||||
"already in the mempool",
|
||||
"txn-already-known",
|
||||
"txn-already-in-mempool",
|
||||
"already in block chain",
|
||||
"already in blockchain",
|
||||
)
|
||||
|
||||
|
||||
class BroadcastOutcome(Enum):
|
||||
ACCEPTED = "accepted"
|
||||
REFUSED = "refused"
|
||||
UNCERTAIN = "uncertain"
|
||||
|
||||
|
||||
class BroadcastVerdict(NamedTuple):
|
||||
outcome: BroadcastOutcome
|
||||
reason: str # the server's own wording or the failure's, for logs and audit rows
|
||||
|
||||
|
||||
class TransactionNetwork(Protocol):
|
||||
"""The part of ElectrumListener a broadcast needs (see electrum/listener.py)."""
|
||||
|
||||
async def transaction_known(self, txid: str) -> bool | None: ...
|
||||
|
||||
def fan_out_transaction(self, raw_tx_hex: str) -> None: ...
|
||||
|
||||
|
||||
def is_already_known(message: str) -> bool:
|
||||
lowered = message.lower()
|
||||
return any(marker in lowered for marker in _ALREADY_KNOWN_MARKERS)
|
||||
|
||||
|
||||
async def broadcast_with_verdict(client, network: TransactionNetwork | None, raw_tx_hex: str, txid: str) -> BroadcastVerdict:
|
||||
"""Broadcast `raw_tx_hex` through the active `client` and say what happened.
|
||||
|
||||
With no `network` to ask, an explicit refusal cannot be verified and therefore
|
||||
comes back UNCERTAIN: the doubt never frees coins. An accepted transaction is
|
||||
sent to every other server too (best effort, TX-21)."""
|
||||
try:
|
||||
await client.broadcast(raw_tx_hex)
|
||||
except ElectrumRpcError as exc:
|
||||
reason = exc.message
|
||||
if is_already_known(reason):
|
||||
known: bool | None = True
|
||||
elif network is None:
|
||||
known = None
|
||||
else:
|
||||
known = await network.transaction_known(txid)
|
||||
if known is True:
|
||||
_fan_out(network, raw_tx_hex)
|
||||
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, reason)
|
||||
if known is False:
|
||||
return BroadcastVerdict(BroadcastOutcome.REFUSED, reason)
|
||||
logger.warning("broadcast of %s was refused (%s) but the refusal could not be verified", txid, reason)
|
||||
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, reason)
|
||||
except Exception as exc:
|
||||
# Timeout, dropped connection, write error: the server may have relayed it.
|
||||
logger.warning("broadcast of %s ended without an answer (%r): outcome uncertain", txid, exc)
|
||||
return BroadcastVerdict(BroadcastOutcome.UNCERTAIN, str(exc) or type(exc).__name__)
|
||||
_fan_out(network, raw_tx_hex)
|
||||
return BroadcastVerdict(BroadcastOutcome.ACCEPTED, "")
|
||||
|
||||
|
||||
def _fan_out(network: TransactionNetwork | None, raw_tx_hex: str) -> None:
|
||||
if network is None:
|
||||
return
|
||||
try:
|
||||
network.fan_out_transaction(raw_tx_hex)
|
||||
except Exception:
|
||||
logger.exception("could not start the fan-out of an accepted transaction")
|
||||
@@ -1,12 +1,15 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import User
|
||||
from app.wallet.hd import derive_pool_address, derive_user_address
|
||||
from app.db.models import Round, User
|
||||
from app.wallet.hd import derive_user_address
|
||||
|
||||
|
||||
async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str:
|
||||
async def own_address_for(
|
||||
session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None
|
||||
) -> str:
|
||||
"""The address that owns every input of a PendingTransaction of this kind —
|
||||
a user's own address for a bet/withdrawal, the pool address for a payout.
|
||||
a user's own address for a bet/withdrawal, the pool address of the round for a
|
||||
payout (B-80: each round has its own, stored on the round).
|
||||
All our builders only ever spend one address's UTXOs per tx (see
|
||||
tx/broadcast.py:_signing_context, which derives the same address alongside
|
||||
the signing key it also needs).
|
||||
@@ -17,6 +20,9 @@ async def own_address_for(session: AsyncSession, kind: str, user_id: int | None)
|
||||
different addresses for the same row.
|
||||
"""
|
||||
if kind == "payout":
|
||||
return derive_pool_address()
|
||||
round_ = await session.get(Round, round_id) if round_id is not None else None
|
||||
if round_ is None or not round_.pool_address:
|
||||
raise ValueError(f"payout of round {round_id}: the round has no pool address")
|
||||
return round_.pool_address
|
||||
user = await session.get(User, user_id)
|
||||
return derive_user_address(user.derivation_index)
|
||||
+262
-50
@@ -13,15 +13,26 @@ balance simply lost them, with no path back short of editing the DB by hand
|
||||
What it does, per PendingTransaction that isn't already terminal:
|
||||
|
||||
* status "building" — we crashed (or were killed) between writing the row and
|
||||
broadcasting. Ask the chain: if the tx is there after all, promote everything
|
||||
to its live state; if it isn't, release the UTXOs and undo the intent.
|
||||
broadcasting. Ask the network: if any server knows one of the row's txids,
|
||||
promote everything to its live state.
|
||||
* status "pending" — broadcast, still unconfirmed. Left alone until it has been
|
||||
unconfirmed for `_ABANDON_AFTER_SECONDS`, since absence from one server's
|
||||
mempool is not proof of death; only then is it abandoned like the above.
|
||||
unconfirmed for `_ABANDON_AFTER_SECONDS`, and even then only examined.
|
||||
|
||||
Deliberately conservative: it never touches a tx the chain knows about, and the
|
||||
grace period is long (multiples of the RBF timeout) so a slow-but-alive tx is
|
||||
bumped by RbfBumper rather than abandoned here.
|
||||
and then *nothing is given up without proof* (B-78, ADR-51, TX-17). A row is
|
||||
abandoned — its coins released — only when
|
||||
|
||||
(a) one of its inputs is spent **in a block** by a transaction that is none of its
|
||||
own versions, and the other servers agree; or
|
||||
(b) no server of the quorum knows any txid of the row **and** sending its latest
|
||||
raw transaction to every server is refused, explicitly and by all of them, for
|
||||
missing or already-spent inputs.
|
||||
|
||||
Absence from one server's history proves nothing — after a restart onto a different
|
||||
server that is the normal state of a tx the new server has not seen — and neither
|
||||
does the age of the row. A refusal for fee reasons is no proof either: such a row is
|
||||
promoted (building) or left to the RBF bumper (pending). A transport failure never
|
||||
resolves anything. Failed rows stay watched for `FAILED_WATCH_DAYS`: if one of them
|
||||
confirms after all, the lottery is paused for the operator (TX-23).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -34,11 +45,24 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, Round, RoundParticipant, UtxoEvent, Withdrawal
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import (
|
||||
PendingTransaction,
|
||||
PendingTransactionTxid,
|
||||
Round,
|
||||
RoundParticipant,
|
||||
UtxoEvent,
|
||||
Withdrawal,
|
||||
)
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.quorum import ServerAnswers
|
||||
from app.electrum.scripthash import address_to_scripthash
|
||||
from app.rounds.events import broadcaster
|
||||
from app.rounds.pause import pause_automatically
|
||||
from app.tx.broadcast import adopt_version
|
||||
from app.tx.outcome import is_already_known
|
||||
from app.tx.pending_address import own_address_for
|
||||
from app.tx.versions import txids_by_row, txids_of
|
||||
from app.wallet.balance import recompute_balance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,14 +74,32 @@ _POLL_INTERVAL_SECONDS = 120
|
||||
_BUILDING_GRACE_SECONDS = 120
|
||||
|
||||
# A "pending" row was accepted by a node once. Give it a wide margin — the RBF
|
||||
# bumper gets several attempts inside this window — before concluding it's gone.
|
||||
# bumper gets several attempts inside this window — before it is even examined.
|
||||
_ABANDON_AFTER_SECONDS = 6 * 60 * 60
|
||||
|
||||
# How long a failed row keeps being looked at (B-78, DAT-72): a transaction given up
|
||||
# for dead can still arrive in a block.
|
||||
FAILED_WATCH_DAYS = 14
|
||||
|
||||
# How many distinct (txid, height) claims of one failed row are put to the quorum per pass.
|
||||
_MAX_RESURRECTION_CLAIMS = 3
|
||||
|
||||
# What a node says when the transaction's inputs do not exist or are spent already.
|
||||
_MISSING_INPUTS_MARKERS = ("missing-inputs", "missingorspent", "missing inputs", "inputs-missing")
|
||||
# What it says when only the fee is the problem: not proof of anything.
|
||||
_FEE_MARKERS = ("min relay fee", "mempool min fee", "insufficient fee", "fee not met", "fee too low", "fee rate")
|
||||
|
||||
|
||||
class PendingTransactionReconciler:
|
||||
def __init__(self, session_factory: async_sessionmaker, get_client: Callable[[], ElectrumClient | None]):
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: async_sessionmaker,
|
||||
get_client: Callable[[], ElectrumClient | None],
|
||||
network=None,
|
||||
):
|
||||
self._session_factory = session_factory
|
||||
self._get_client = get_client
|
||||
self._network = network # the ElectrumListener: histories of every server, rebroadcast, spenders
|
||||
|
||||
async def run(self) -> None:
|
||||
# Runs once promptly at startup: a crash mid-broadcast is exactly the case
|
||||
@@ -66,7 +108,7 @@ class PendingTransactionReconciler:
|
||||
client = self._get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
await reconcile_once(self._session_factory, client)
|
||||
await reconcile_once(self._session_factory, client, self._network)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -74,81 +116,246 @@ class PendingTransactionReconciler:
|
||||
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int:
|
||||
"""Returns how many rows were resolved (promoted or abandoned).
|
||||
class _Candidate:
|
||||
__slots__ = ("row_id", "status", "txid", "scripthash", "known_txids", "raw_hex")
|
||||
|
||||
Existence is decided by checking whether a row's own address's history
|
||||
(blockchain.scripthash.get_history) includes its txid at all — mempool or
|
||||
mined — rather than asking blockchain.transaction.get for a verbose reply
|
||||
(B-41): several Electrum server implementations and versions reject the
|
||||
verbose flag outright, and the previous substring-matching on the error
|
||||
text (looking for "missing", "not found", ...) was fragile as the basis for
|
||||
a decision that releases funds. A transport failure fetching history still
|
||||
raises and leaves the row alone until next time — get_history not
|
||||
returning our txid is the only thing that means "gone". History is cached
|
||||
per scripthash within one pass, since every "payout" row shares the same
|
||||
pool address.
|
||||
def __init__(self, row_id, status, txid, scripthash, known_txids, raw_hex):
|
||||
self.row_id = row_id
|
||||
self.status = status
|
||||
self.txid = txid
|
||||
self.scripthash = scripthash
|
||||
self.known_txids = known_txids
|
||||
self.raw_hex = raw_hex
|
||||
|
||||
|
||||
async def reconcile_once(
|
||||
session_factory: async_sessionmaker, client: ElectrumClient, network=None
|
||||
) -> int:
|
||||
"""Returns how many rows were resolved (promoted, abandoned, realigned or
|
||||
resurrected).
|
||||
|
||||
Existence is decided by asking every server of the quorum for the history of the
|
||||
row's own address (blockchain.scripthash.get_history) and looking for **every** txid
|
||||
the row has had, mempool or mined (B-41, B-77, B-78) — never for a verbose
|
||||
blockchain.transaction.get reply, which several servers reject outright, and never
|
||||
on error text. History is cached per scripthash within one pass, since the payout
|
||||
rows of one round share its pool address (B-80). With no `network` only the active
|
||||
server is asked, which can promote a row but never abandon one.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.scalars(
|
||||
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending")))
|
||||
select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending", "failed")))
|
||||
)
|
||||
).all()
|
||||
candidates = []
|
||||
txids_by_id = await txids_by_row(session, {row.id: row.current_txid for row in rows})
|
||||
candidates: list[_Candidate] = []
|
||||
for row in rows:
|
||||
if not _is_due(row, now):
|
||||
continue
|
||||
try:
|
||||
address = await own_address_for(session, row.kind, row.user_id)
|
||||
address = await own_address_for(session, row.kind, row.user_id, row.round_id)
|
||||
scripthash = address_to_scripthash(address)
|
||||
except Exception:
|
||||
logger.exception("could not derive the address for pending_transaction %s", row.id)
|
||||
continue
|
||||
candidates.append((row.id, row.status, row.current_txid, scripthash))
|
||||
candidates.append(
|
||||
_Candidate(row.id, row.status, row.current_txid, scripthash, txids_by_id[row.id], row.raw_tx_hex)
|
||||
)
|
||||
|
||||
resolved = 0
|
||||
history_cache: dict[str, list[dict]] = {}
|
||||
for row_id, status, txid, scripthash in candidates:
|
||||
history_cache: dict[str, ServerAnswers] = {}
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if scripthash not in history_cache:
|
||||
history_cache[scripthash] = await client.get_history(scripthash)
|
||||
exists = any(entry.get("tx_hash") == txid for entry in history_cache[scripthash])
|
||||
if candidate.scripthash not in history_cache:
|
||||
history_cache[candidate.scripthash] = await _histories(client, network, candidate.scripthash)
|
||||
answers = history_cache[candidate.scripthash]
|
||||
decision = await _decide(network, candidate, answers)
|
||||
except Exception:
|
||||
# Transport/server problem — say nothing about this tx and try again on
|
||||
# the next pass rather than abandoning a tx that may be perfectly alive.
|
||||
logger.warning("could not check pending_transaction %s (txid %s) against the chain", row_id, txid)
|
||||
logger.warning(
|
||||
"could not check pending_transaction %s (txid %s) against the chain",
|
||||
candidate.row_id,
|
||||
candidate.txid,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
if decision is None:
|
||||
continue
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(PendingTransaction, row_id)
|
||||
if row is None or row.status != status:
|
||||
continue # something else moved it while we were asking
|
||||
if exists:
|
||||
await begin_immediate(session) # B-88: the re-check and the resolution under one lock
|
||||
row = await session.get(PendingTransaction, candidate.row_id)
|
||||
# TX-26: the status *and* the current txid must still be what they were when
|
||||
# the network was asked — a confirmation or an RBF in the meantime changes
|
||||
# what the answer means.
|
||||
if row is None or row.status != candidate.status or row.current_txid != candidate.txid:
|
||||
continue
|
||||
kind, payload = decision
|
||||
changed = False
|
||||
if kind == "resurrected":
|
||||
await _resurrect(session, row, *payload)
|
||||
changed = True
|
||||
elif kind == "present":
|
||||
if payload and payload != row.current_txid:
|
||||
# Another version of the row is the one on the network (B-77, TX-19):
|
||||
# the row follows it, so the inputs and the domain row point at it.
|
||||
await adopt_version(session, row, payload)
|
||||
changed = True
|
||||
if row.status == "building":
|
||||
await _promote(session, row)
|
||||
resolved += 1
|
||||
else:
|
||||
await _abandon(session, row, "not found on chain")
|
||||
resolved += 1
|
||||
changed = True
|
||||
elif kind == "promote":
|
||||
await _promote(session, row)
|
||||
changed = True
|
||||
elif kind == "abandon":
|
||||
await _abandon(session, row, payload)
|
||||
changed = True
|
||||
await session.commit()
|
||||
resolved += changed
|
||||
broadcaster.publish()
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
async def _histories(client: ElectrumClient, network, scripthash: str) -> ServerAnswers:
|
||||
if network is not None:
|
||||
return await network.get_histories(scripthash)
|
||||
# No quorum to ask: the active server alone can show a transaction exists, but its
|
||||
# silence can never be taken for absence (`complete` stays false).
|
||||
return ServerAnswers(await client.get_history(scripthash), (), 1)
|
||||
|
||||
|
||||
def _entries_for(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[str, int, bool]]:
|
||||
"""(txid, height, from_active) for every history entry of every answering server that
|
||||
is one of the row's txids."""
|
||||
found: list[tuple[str, int, bool]] = []
|
||||
if answers.active is not None:
|
||||
found += [(e["tx_hash"], e.get("height") or 0, True) for e in answers.active if e.get("tx_hash") in known_txids]
|
||||
for history in answers.answered_others:
|
||||
found += [(e["tx_hash"], e.get("height") or 0, False) for e in history if e.get("tx_hash") in known_txids]
|
||||
return found
|
||||
|
||||
|
||||
def _confirmed_candidates(answers: ServerAnswers, known_txids: set[str]) -> list[tuple[str, int]]:
|
||||
"""(txid, height) of every txid of the row that any answering server lists in a block,
|
||||
the one most servers agree on first. They are only *claims*: each is proven by the
|
||||
quorum's merkle proofs before it is believed (B-83, TX-22)."""
|
||||
votes: dict[tuple[str, int], int] = {}
|
||||
for history in answers.all:
|
||||
for entry in history:
|
||||
if entry.get("tx_hash") in known_txids and (entry.get("height") or 0) > 0:
|
||||
key = (entry["tx_hash"], entry["height"])
|
||||
votes[key] = votes.get(key, 0) + 1
|
||||
return [key for key, _ in sorted(votes.items(), key=lambda item: -item[1])]
|
||||
|
||||
|
||||
async def _decide(network, candidate: _Candidate, answers: ServerAnswers):
|
||||
"""What the network says about one row, as ("present", txid | None), ("promote", None),
|
||||
("abandon", reason), ("resurrected", (txid, height)) or None for "nothing to do or
|
||||
not enough to tell". Pure reads — nothing is written here."""
|
||||
if candidate.status == "failed":
|
||||
if network is None:
|
||||
return None # nothing can be proven without the quorum
|
||||
for txid, height in _confirmed_candidates(answers, candidate.known_txids)[:_MAX_RESURRECTION_CLAIMS]:
|
||||
if await network.confirmation_proven(txid, height):
|
||||
return ("resurrected", (txid, height))
|
||||
return None
|
||||
|
||||
found = _entries_for(answers, candidate.known_txids)
|
||||
if found:
|
||||
# Prefer a version that is mined, then the current one, then any.
|
||||
mined = [txid for txid, height, _ in found if height > 0]
|
||||
present = {txid for txid, _, _ in found}
|
||||
chosen = mined[0] if mined else (candidate.txid if candidate.txid in present else found[0][0])
|
||||
return ("present", chosen)
|
||||
|
||||
if not answers.complete:
|
||||
logger.warning(
|
||||
"pending_transaction %s (txid %s): too few servers answered to say the tx is unknown — left as is",
|
||||
candidate.row_id,
|
||||
candidate.txid,
|
||||
)
|
||||
return None
|
||||
if network is None:
|
||||
return None
|
||||
|
||||
# No server of the quorum knows any txid of the row. That alone proves nothing (TX-15).
|
||||
tx = Transaction.parse(bytes.fromhex(candidate.raw_hex))
|
||||
outpoints = {(vin.txid.hex(), vin.vout) for vin in tx.vin}
|
||||
spender = await network.confirmed_spender(candidate.scripthash, candidate.known_txids, outpoints)
|
||||
if spender is not None:
|
||||
return ("abandon", f"input spent in a block by {spender}")
|
||||
|
||||
rebroadcast: ServerAnswers = await network.rebroadcast_everywhere(candidate.raw_hex)
|
||||
verdicts = rebroadcast.all
|
||||
if any(v[0] == "accepted" or (v[0] == "refused" and is_already_known(v[1])) for v in verdicts):
|
||||
# It was alive after all (or is again): the same as having found it.
|
||||
return ("present", None)
|
||||
refusals = [v[1].lower() for v in verdicts if v[0] == "refused"]
|
||||
if rebroadcast.complete and len(refusals) == len(verdicts) and all(
|
||||
any(marker in message for marker in _MISSING_INPUTS_MARKERS) for message in refusals
|
||||
):
|
||||
return ("abandon", f"rebroadcast refused for missing inputs: {refusals[0]}")
|
||||
if candidate.status == "building" and any(any(marker in m for marker in _FEE_MARKERS) for m in refusals):
|
||||
return ("promote", None) # not proof: let the RBF bumper accelerate it (TX-17)
|
||||
return None
|
||||
|
||||
|
||||
def _is_due(row: PendingTransaction, now: datetime) -> bool:
|
||||
if row.status == "failed":
|
||||
# B-78 (TX-23, DAT-72): watched every pass for FAILED_WATCH_DAYS from failed_at.
|
||||
failed_at = row.failed_at or row.updated_at
|
||||
return failed_at is not None and now < failed_at.replace(tzinfo=timezone.utc) + timedelta(days=FAILED_WATCH_DAYS)
|
||||
# Deliberately broadcast_at (the *first* broadcast), not last_broadcast_at: an
|
||||
# RBF bump used to overwrite this same field, which reset this grace period on
|
||||
# every bump and meant a repeatedly-bumped-but-never-mined tx was never
|
||||
# abandoned (B-27). tx/broadcast.py:bump_fee now only ever touches
|
||||
# last_broadcast_at, so this keeps measuring from when the tx first appeared,
|
||||
# no matter how many times it's since been bumped.
|
||||
# no matter how many times it's since been bumped. The period only decides from
|
||||
# when a row is *examined*; giving it up still needs proof (TX-14, TX-17).
|
||||
grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS
|
||||
return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace)
|
||||
|
||||
|
||||
async def _resurrect(session: AsyncSession, row: PendingTransaction, txid: str, height: int) -> None:
|
||||
"""A row given up for dead was mined (B-78, TX-23, DAT-72). The row becomes
|
||||
`confirmed` and the lottery is paused for the operator: the domain rows the
|
||||
abandonment already rolled back (a deleted participant, a failed withdrawal, released
|
||||
coins, a rebuilt payout) are *not* put back by the system, which cannot know what has
|
||||
happened to them since."""
|
||||
if txid != row.current_txid:
|
||||
version_raw = None
|
||||
version = await session.scalar(
|
||||
select(PendingTransactionTxid).where(
|
||||
PendingTransactionTxid.txid == txid, PendingTransactionTxid.pending_transaction_id == row.id
|
||||
)
|
||||
)
|
||||
if version is not None and version.raw_hex:
|
||||
version_raw = version.raw_hex
|
||||
row.current_txid = txid
|
||||
if version_raw:
|
||||
row.raw_tx_hex = version_raw
|
||||
row.status = "confirmed"
|
||||
await write_audit_log(
|
||||
session,
|
||||
"pending_tx_resurrected",
|
||||
{"pending_transaction_id": row.id, "kind": row.kind, "txid": txid, "block_height": height},
|
||||
user_id=row.user_id,
|
||||
round_id=row.round_id,
|
||||
)
|
||||
await pause_automatically(session, "resurrected_tx")
|
||||
logger.error(
|
||||
"%s pending_transaction %s was given up as failed but tx %s is in block %s — lottery paused for the operator",
|
||||
row.kind,
|
||||
row.id,
|
||||
txid,
|
||||
height,
|
||||
)
|
||||
|
||||
|
||||
async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
|
||||
"""The tx did make it onto the chain before we died — finish what phase 2 of
|
||||
place_bet/request_withdrawal would have done."""
|
||||
@@ -178,10 +385,12 @@ async def _promote(session: AsyncSession, row: PendingTransaction) -> None:
|
||||
async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str) -> None:
|
||||
"""The tx is gone for good. Release whatever it reserved so the funds come back,
|
||||
and roll the domain row back to something truthful."""
|
||||
reason = reason[:128]
|
||||
row.status = "failed"
|
||||
row.failure_reason = reason[:128]
|
||||
row.failure_reason = reason
|
||||
row.failed_at = datetime.now(timezone.utc) # B-78: starts the 14-day watch
|
||||
|
||||
released = await _release_inputs(session, row)
|
||||
released = await _release_inputs(session, row, await txids_of(session, row))
|
||||
|
||||
if row.kind == "bet":
|
||||
participant = await session.scalar(
|
||||
@@ -202,8 +411,10 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
|
||||
elif row.kind == "payout":
|
||||
# Payout funds come from the pool address, which isn't tracked in
|
||||
# utxo_events, so there's nothing to release. Clearing payout_txid leaves the
|
||||
# round in "paying_out" with no tx attached, which is the state an operator
|
||||
# (or a future payout-retry routine — still an open gap) can act on.
|
||||
# round in "paying_out" with no tx attached, which is exactly the state the
|
||||
# scheduler's payout retry picks up (B-26: `_retry_payout_if_due`, one attempt
|
||||
# per 60s), so an abandoned payout is rebuilt on its own rather than waiting
|
||||
# for an operator — the log line below is the alert, not the recovery path.
|
||||
round_ = await session.get(Round, row.round_id) if row.round_id else None
|
||||
if round_ is not None and round_.status == "paying_out":
|
||||
round_.payout_txid = None
|
||||
@@ -237,7 +448,7 @@ async def _abandon(session: AsyncSession, row: PendingTransaction, reason: str)
|
||||
)
|
||||
|
||||
|
||||
async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int:
|
||||
async def _release_inputs(session: AsyncSession, row: PendingTransaction, own_txids: set[str] | None = None) -> int:
|
||||
"""Clear spent_txid on every UTXO this transaction consumed, so the balance
|
||||
counts them again. The inputs come from the stored raw tx, which is kept current
|
||||
across RBF bumps, so this works for a bumped tx too."""
|
||||
@@ -253,8 +464,9 @@ async def _release_inputs(session: AsyncSession, row: PendingTransaction) -> int
|
||||
select(UtxoEvent).where(UtxoEvent.txid == vin.txid.hex(), UtxoEvent.vout == vin.vout)
|
||||
)
|
||||
# Only release what this tx actually reserved: if another tx has since spent
|
||||
# the same UTXO, its claim is the live one and must not be cleared.
|
||||
if utxo is not None and utxo.spent_txid == row.current_txid:
|
||||
# the same UTXO, its claim is the live one and must not be cleared. "This tx"
|
||||
# is any version of the row (B-77, DAT-13), not only its current one.
|
||||
if utxo is not None and utxo.spent_txid in (own_txids or {row.current_txid}):
|
||||
utxo.spent_txid = None
|
||||
released += 1
|
||||
return released
|
||||
@@ -0,0 +1,53 @@
|
||||
"""The versions of a pending transaction (B-77, DAT-70, DAT-71, TX-19).
|
||||
|
||||
A bumped transaction has several txids and any of them may be the one that confirms.
|
||||
Everything that looks a transaction up on the chain asks for all of them."""
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import PendingTransaction, PendingTransactionTxid
|
||||
|
||||
|
||||
async def record_version(session: AsyncSession, pending: PendingTransaction, txid: str, raw_hex: str) -> None:
|
||||
"""Write `txid` as a version of `pending` in the caller's transaction. Idempotent for
|
||||
the same pair (a bump retried after an uncertain broadcast rebuilds the same
|
||||
transaction); a txid already held by another row is an error — it is unique across
|
||||
the platform."""
|
||||
if pending.id is None:
|
||||
await session.flush()
|
||||
existing = await session.scalar(select(PendingTransactionTxid).where(PendingTransactionTxid.txid == txid))
|
||||
if existing is not None:
|
||||
if existing.pending_transaction_id != pending.id:
|
||||
raise ValueError(f"txid {txid} already belongs to pending_transaction {existing.pending_transaction_id}")
|
||||
return
|
||||
session.add(PendingTransactionTxid(pending_transaction_id=pending.id, txid=txid, raw_hex=raw_hex))
|
||||
|
||||
|
||||
async def txids_of(session: AsyncSession, pending: PendingTransaction) -> set[str]:
|
||||
"""Every txid of the row: all its recorded versions plus `current_txid` (a row
|
||||
written before the table existed, or by a test, may have no version row for it)."""
|
||||
rows = await session.scalars(
|
||||
select(PendingTransactionTxid.txid).where(PendingTransactionTxid.pending_transaction_id == pending.id)
|
||||
)
|
||||
return {pending.current_txid, *rows.all()}
|
||||
|
||||
|
||||
async def txids_by_row(session: AsyncSession, current: dict[int, str]) -> dict[int, set[str]]:
|
||||
"""`txids_of` for many rows at once; `current` maps row id -> current_txid."""
|
||||
result = {row_id: {txid} for row_id, txid in current.items()}
|
||||
if not result:
|
||||
return result
|
||||
rows = await session.execute(
|
||||
select(PendingTransactionTxid.pending_transaction_id, PendingTransactionTxid.txid).where(
|
||||
PendingTransactionTxid.pending_transaction_id.in_(list(result))
|
||||
)
|
||||
)
|
||||
for row_id, txid in rows.all():
|
||||
result[row_id].add(txid)
|
||||
return result
|
||||
|
||||
|
||||
async def delete_versions(session: AsyncSession, pending_id: int) -> None:
|
||||
"""The versions go with a `bet` row that a verified refusal deletes (DAT-38)."""
|
||||
await session.execute(delete(PendingTransactionTxid).where(PendingTransactionTxid.pending_transaction_id == pending_id))
|
||||
+31
-8
@@ -50,14 +50,37 @@ def derive_user_wif(derivation_index: int) -> str:
|
||||
return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(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_key(round_id: int | None = None) -> HDKey:
|
||||
"""A pool key. With a `round_id`, that round's own key on branch 2 of the account
|
||||
(`m/84'/746'/0'/2/<round_id>`, B-80, RND-35): its bets are paid to it and its
|
||||
payout is signed with it, so one round's money and history never mix with another's.
|
||||
Without one, the **reserve** key at branch 1, index 0 — the single pool address every
|
||||
round used before B-80, still the pool of the rounds opened back then and the
|
||||
destination of the payout's bump reserve. Branch 0 is users. Not a spec requirement
|
||||
of the coin, an implementation choice to keep every key under the one encrypted
|
||||
master key rather than in separate secrets."""
|
||||
if round_id is None:
|
||||
return _load_account_key().derive("1/0")
|
||||
if round_id < 1:
|
||||
raise ValueError(f"invalid round id {round_id}")
|
||||
return _load_account_key().derive(f"2/{round_id}")
|
||||
|
||||
|
||||
def derive_pool_address() -> str:
|
||||
pub = derive_pool_key().to_public()
|
||||
def derive_pool_address(round_id: int | None = None) -> str:
|
||||
"""The pool address of a round (B-80), or the reserve address without a `round_id`."""
|
||||
pub = derive_pool_key(round_id).to_public()
|
||||
return script.p2wpkh(pub).address(network=PLM_MAINNET)
|
||||
|
||||
|
||||
def derive_round_pool_key(round_id: int, pool_address: str) -> HDKey:
|
||||
"""The key that controls `pool_address`, the address stored on round `round_id`.
|
||||
|
||||
A round opened before B-80 stores the reserve address and is signed with the reserve
|
||||
key; every other round with its own. Checking the derived address against the stored
|
||||
one means a key can never be used for money it does not control, and a mismatch (a
|
||||
different master key, a corrupted row) fails loudly instead of building a transaction
|
||||
that spends someone else's coins."""
|
||||
for key in (derive_pool_key(round_id), derive_pool_key()):
|
||||
if script.p2wpkh(key.to_public()).address(network=PLM_MAINNET) == pool_address:
|
||||
return key
|
||||
raise ValueError(f"round {round_id}: no pool key controls the stored address {pool_address!r}")
|
||||
@@ -9,3 +9,14 @@ def encrypt_xprv(xprv_base58: str) -> bytes:
|
||||
|
||||
def decrypt_xprv(token: bytes) -> str:
|
||||
return Fernet(settings.xprv_encryption_key).decrypt(token).decode()
|
||||
|
||||
|
||||
def encrypt_round_seed(server_seed_hex: str) -> str:
|
||||
"""B-73 (DAT-64, SEC-56): a round's secret encrypted at rest with the same key that
|
||||
protects the master xprv, so a copy of the database alone never reveals it. The Fernet
|
||||
token is ASCII, stored as text."""
|
||||
return Fernet(settings.xprv_encryption_key).encrypt(server_seed_hex.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_round_seed(token: str) -> str:
|
||||
return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode()
|
||||
+93
-22
@@ -33,14 +33,37 @@ DUST_LIMIT_SATS = 294
|
||||
# eating further and further into the sender's change with no limit.
|
||||
MAX_FEE_RATE_SAT_VB = 10_000
|
||||
|
||||
# Ceiling on how many UTXOs one transaction may spend (B-48). Every extra input costs
|
||||
# ~68 vbytes of fee, and that fee comes out of the amount being moved — so an address
|
||||
# fragmented into hundreds of small deposits would silently erode its own bet (shrinking
|
||||
# the user's share of the pool) or withdrawal, and past a few hundred inputs the tx also
|
||||
# stops being standard and gets refused at broadcast. Failing the build with a
|
||||
# translatable error is the honest outcome; consolidating the address is the way out.
|
||||
# Ceiling on how many UTXOs one *user* transaction (bet, withdrawal) may spend (B-48).
|
||||
# Every extra input costs ~68 vbytes of fee, and that fee comes out of the amount being
|
||||
# moved — so an address fragmented into hundreds of small deposits would silently erode
|
||||
# its own bet (shrinking the user's share of the pool) or withdrawal. Failing the build
|
||||
# with a translatable error is the honest outcome; consolidating the address is the way
|
||||
# out. This is a *user-protection* limit, which is why the payout gets its own, far
|
||||
# higher one below.
|
||||
MAX_TX_INPUTS = 50
|
||||
|
||||
# Ceiling on the payout's inputs (B-52). The payout is not a user spending their own
|
||||
# fragmented balance: it drains the pool, whose UTXO count is simply the number of bets
|
||||
# in the round, and its fee comes out of a 70% share of that whole pool. So the erosion
|
||||
# argument behind MAX_TX_INPUTS doesn't apply here — 400 inputs at 1 sat/vB cost ~27_300
|
||||
# sat, i.e. ~0.00027 PLM out of the winner's share — and reusing that limit was what made
|
||||
# any round past ~50 participants unpayable: select_utxos raised too_many_inputs, the
|
||||
# round stayed "paying_out" retrying forever, and since no new round may open while one
|
||||
# is active, the whole lottery stopped with the pool stuck (B-52).
|
||||
#
|
||||
# What actually bounds this is relay policy: a non-standard transaction is refused at
|
||||
# broadcast past 100 kvB, which at ~68 vbytes per input is ~1470 inputs. 500 stays at
|
||||
# roughly a third of that budget, and signing that many inputs costs ~0.4s of event loop
|
||||
# (measured), once per round, inside a background task.
|
||||
MAX_PAYOUT_TX_INPUTS = 500
|
||||
|
||||
# The most participants one round may hold (B-52). Enforced where the money is not yet
|
||||
# committed — app/bets/service.py refuses the bet — instead of being discovered at payout
|
||||
# time, when the bets are already in the pool and there is no way back. Deliberately below
|
||||
# MAX_PAYOUT_TX_INPUTS: the payout also has to be able to spend whatever change UTXOs
|
||||
# earlier rounds left in the pool, so the gap is the headroom for those.
|
||||
MAX_PARTICIPANTS_PER_ROUND = 400
|
||||
|
||||
|
||||
class InsufficientFundsError(Exception):
|
||||
"""`code` is the machine-readable identifier the API layer forwards to the
|
||||
@@ -59,6 +82,11 @@ class InsufficientFundsError(Exception):
|
||||
self.params = params
|
||||
|
||||
|
||||
# B-73 (DAT-63): the recipient is always the first output of a single-recipient transaction
|
||||
# (the change, when there is one, follows it) — which is what a bet records as `bet_vout`.
|
||||
RECIPIENT_VOUT = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Utxo:
|
||||
txid: str
|
||||
@@ -80,24 +108,36 @@ 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]:
|
||||
def select_utxos(
|
||||
utxos: list[Utxo], target_sats: int, max_inputs: int = MAX_TX_INPUTS
|
||||
) -> 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.
|
||||
|
||||
At most MAX_TX_INPUTS are ever selected (B-48): if the largest MAX_TX_INPUTS
|
||||
At most `max_inputs` are ever selected (B-48): if the largest `max_inputs`
|
||||
UTXOs don't cover the target, the balance is there but too fragmented to spend
|
||||
in one transaction, which is a different failure from having no funds at all
|
||||
and gets its own code."""
|
||||
ordered = sorted(utxos, key=lambda u: u.amount_sats, reverse=True)
|
||||
and gets its own code.
|
||||
|
||||
The cap is a parameter, not the constant it used to be, because the two callers
|
||||
want different ones (B-52): MAX_TX_INPUTS protects a user from a fee that would
|
||||
eat into their own bet/withdrawal, while the payout drains a pool holding one
|
||||
UTXO per bet and needs MAX_PAYOUT_TX_INPUTS to be able to pay a full round at
|
||||
all."""
|
||||
# B-75: a UTXO below the dust limit is never offered, even if one is already on
|
||||
# record: it costs more to spend than it is worth.
|
||||
ordered = sorted(
|
||||
(u for u in utxos if u.amount_sats >= DUST_LIMIT_SATS), key=lambda u: u.amount_sats, reverse=True
|
||||
)
|
||||
selected: list[Utxo] = []
|
||||
total = 0
|
||||
for utxo in ordered:
|
||||
if len(selected) == MAX_TX_INPUTS:
|
||||
if len(selected) == max_inputs:
|
||||
raise InsufficientFundsError(
|
||||
f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed",
|
||||
f"balance too fragmented: more than {max_inputs} inputs would be needed",
|
||||
code="too_many_inputs",
|
||||
max_inputs=MAX_TX_INPUTS,
|
||||
max_inputs=max_inputs,
|
||||
)
|
||||
selected.append(utxo)
|
||||
total += utxo.amount_sats
|
||||
@@ -115,6 +155,7 @@ def build_signed_transaction(
|
||||
amount_sats: int,
|
||||
change_address: str,
|
||||
fee_rate_sat_vb: int,
|
||||
reduce_amount_to_keep_change: bool = False,
|
||||
) -> BuiltTransaction:
|
||||
"""Build, sign and finalize a single-recipient P2WPKH transaction with change
|
||||
back to change_address.
|
||||
@@ -124,21 +165,47 @@ def build_signed_transaction(
|
||||
spec everywhere a single-recipient tx is used (bet, withdrawal): "fee deducted
|
||||
from the amount being moved", not paid on top by the sender.
|
||||
|
||||
A change amount below DUST_LIMIT_SATS is dropped and left to the fee — paying it
|
||||
back to ourselves would produce an unrelayable transaction. The fee estimate
|
||||
already assumes two outputs, so dropping one never underpays.
|
||||
B-62: the transaction always keeps a change output of at least DUST_LIMIT_SATS.
|
||||
Change used to be folded into the fee whenever it came out below the dust limit,
|
||||
which for an amount equal to the whole input total (the UI's "withdraw
|
||||
everything" checkbox, or a bet from a balance exactly equal to the bet amount)
|
||||
produced a single-output transaction — and `tx/broadcast.py:bump_fee` has nothing
|
||||
to shrink there, so it raised RbfError every 30s until the reconciler abandoned
|
||||
the row hours later. Adding inputs instead is no answer for this case in
|
||||
particular: the transaction already spends every UTXO the sender has.
|
||||
|
||||
What happens when the change would be too small depends on who's asking, hence
|
||||
`reduce_amount_to_keep_change`:
|
||||
|
||||
- withdrawals pass True — the amount moved is reduced just enough to leave a
|
||||
dust-limit change output. The fee already comes out of the withdrawn amount by
|
||||
design, so this is the same rule applied a little harder, and the caller
|
||||
records what was actually sent (`Withdrawal.amount_sent_sats`).
|
||||
- bets pass False (the default) and get an InsufficientFundsError instead: the
|
||||
bet is a fixed price that cannot be quietly reduced, and "a user's balance must
|
||||
never exactly equal the bet" is a documented invariant of the PLAY phase. The
|
||||
player needs a little more than the bet amount, which is what the error says.
|
||||
"""
|
||||
selected, total_in = select_utxos(utxos, amount_sats)
|
||||
fee = estimate_vsize(len(selected), 2) * fee_rate_sat_vb
|
||||
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
if not reduce_amount_to_keep_change:
|
||||
raise InsufficientFundsError(
|
||||
f"the amount leaves no change output: {DUST_LIMIT_SATS - change} more sats are "
|
||||
"needed for the transaction to stay fee-bumpable",
|
||||
code="balance_leaves_no_change",
|
||||
required_extra_sats=DUST_LIMIT_SATS - change,
|
||||
)
|
||||
amount_sats -= DUST_LIMIT_SATS - change
|
||||
change = DUST_LIMIT_SATS
|
||||
|
||||
recipient_amount = amount_sats - fee
|
||||
if recipient_amount <= 0:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to cover the network fee", code="amount_below_network_fee"
|
||||
)
|
||||
change = total_in - amount_sats
|
||||
if change < DUST_LIMIT_SATS:
|
||||
fee += change # dust change is unspendable and unrelayable — miners get it
|
||||
change = 0
|
||||
if recipient_amount < DUST_LIMIT_SATS:
|
||||
raise InsufficientFundsError(
|
||||
"amount too small to be sent (dust)", code="amount_below_dust_limit"
|
||||
@@ -203,9 +270,13 @@ def build_payout_transaction(
|
||||
absorbs the tx fee — the commission (fee_address) output is untouched.
|
||||
|
||||
As in build_signed_transaction, dust-sized pool change is left to the fee
|
||||
rather than creating an unrelayable output (B-06)."""
|
||||
rather than creating an unrelayable output (B-06).
|
||||
|
||||
Selection uses MAX_PAYOUT_TX_INPUTS, not the much stricter user-facing
|
||||
MAX_TX_INPUTS (B-52) — the pool holds one UTXO per bet, so the user-protection
|
||||
cap made every round past ~50 participants impossible to pay."""
|
||||
target = winner_share_sats + commission_sats
|
||||
selected, total_in = select_utxos(utxos, target)
|
||||
selected, total_in = select_utxos(utxos, target, max_inputs=MAX_PAYOUT_TX_INPUTS)
|
||||
fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change
|
||||
winner_amount = winner_share_sats - fee
|
||||
if winner_amount < DUST_LIMIT_SATS:
|
||||
|
||||
+93
-10
@@ -1,16 +1,23 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from embit import script
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiError
|
||||
from app.audit.log import write_audit_log
|
||||
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
|
||||
from app.db.base import begin_immediate
|
||||
from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.rounds.config import get_round_config
|
||||
from app.rounds.events import broadcaster
|
||||
from app.tx.versions import record_version
|
||||
from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict
|
||||
from app.wallet.address import is_valid_plm_address
|
||||
from app.wallet.balance import compute_pending_balance, recompute_balance
|
||||
from app.wallet.hd import derive_user_key
|
||||
from app.wallet.hd import derive_pool_address, derive_user_key
|
||||
from app.wallet.psbt_builder import (
|
||||
BuiltTransaction,
|
||||
InsufficientFundsError,
|
||||
@@ -19,12 +26,20 @@ from app.wallet.psbt_builder import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WithdrawalError(ApiError):
|
||||
pass
|
||||
|
||||
|
||||
async def request_withdrawal(
|
||||
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
|
||||
session: AsyncSession,
|
||||
client: ElectrumClient,
|
||||
user: User,
|
||||
external_address: str,
|
||||
amount_sats: int,
|
||||
network: TransactionNetwork | None = None,
|
||||
) -> Withdrawal:
|
||||
# Checked before anything else: an address from another chain parses fine as a
|
||||
# witness program (see wallet/address.py), so without this the tx would build,
|
||||
@@ -43,7 +58,35 @@ async def request_withdrawal(
|
||||
"that is your own deposit address — withdraw to an external wallet instead",
|
||||
)
|
||||
|
||||
# B-88 (DAT-74, TX-28): the write lock before the first read that decides
|
||||
# anything, so the UTXOs selected below are the ones phase 1 then marks spent.
|
||||
# Taken only after the checks that need no database at all, so a malformed
|
||||
# request never queues for it. No lock within busy_timeout means nothing has been
|
||||
# built or broadcast: the caller is asked to retry.
|
||||
try:
|
||||
await begin_immediate(session)
|
||||
except OperationalError as exc:
|
||||
raise WithdrawalError(
|
||||
"service_busy", "the service is busy right now, nothing was sent — please try again"
|
||||
) from exc
|
||||
|
||||
config = await get_round_config(session)
|
||||
|
||||
# B-80 (WDR-14): never to an address of the platform itself. Coins sent to a round's
|
||||
# pool address land among that round's UTXOs, and to the reserve or fee address they
|
||||
# leave the user's balance for good; the amount would be spent from the balance and
|
||||
# then belong to nobody. Compared lower-cased, since bech32 is case-insensitive.
|
||||
platform_addresses = {derive_pool_address().lower(), config.fee_address.strip().lower()}
|
||||
platform_addresses.update(
|
||||
address.lower() for address in await session.scalars(select(Round.pool_address).distinct()) if address
|
||||
)
|
||||
platform_addresses.discard("")
|
||||
if external_address.strip().lower() in platform_addresses:
|
||||
raise WithdrawalError(
|
||||
"withdrawal_to_platform_address",
|
||||
"that is an address of the platform itself — withdraw to an external wallet instead",
|
||||
)
|
||||
|
||||
if amount_sats < config.bet_amount_sats:
|
||||
raise WithdrawalError(
|
||||
"amount_below_minimum",
|
||||
@@ -87,6 +130,13 @@ async def request_withdrawal(
|
||||
amount_sats=amount_sats,
|
||||
change_address=user.address,
|
||||
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||
# B-62: "withdraw everything" asks for the whole confirmed balance, which
|
||||
# would leave no change output and therefore nothing bump_fee could
|
||||
# shrink — the one tx shape RBF cannot rescue, and the UI's default
|
||||
# withdrawal path at that. Move a dust limit less instead of producing an
|
||||
# unbumpable transaction; amount_sent_sats below records what actually
|
||||
# went out, which is already how a fee-deducted withdrawal is reported.
|
||||
reduce_amount_to_keep_change=True,
|
||||
)
|
||||
except InsufficientFundsError as exc:
|
||||
raise WithdrawalError(exc.code, str(exc), **exc.params) from exc
|
||||
@@ -118,16 +168,47 @@ async def request_withdrawal(
|
||||
status="building",
|
||||
)
|
||||
session.add(pending)
|
||||
await record_version(session, pending, built.txid, built.raw_hex) # B-77: the first version, with the row
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
await client.broadcast(built.raw_hex)
|
||||
except Exception as exc:
|
||||
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, str(exc))
|
||||
raise WithdrawalError(
|
||||
"broadcast_failed", f"the network refused the transaction: {exc}"
|
||||
) from exc
|
||||
# B-76 (TX-04, TX-20): only a verified refusal releases the coins. A timeout, a
|
||||
# drop after the write, or a refusal nobody could verify may mean the transaction
|
||||
# left: the withdrawal would then be paid out while its row said "failed" and its
|
||||
# coins were spendable again. Those outcomes leave the rows "building".
|
||||
verdict = await broadcast_with_verdict(client, network, built.raw_hex, built.txid)
|
||||
if verdict.outcome is BroadcastOutcome.REFUSED:
|
||||
try:
|
||||
await _release_failed_withdrawal(session, withdrawal, pending, built, user.id, verdict.reason)
|
||||
except OperationalError:
|
||||
# B-88: the release could not be written (database busy). The rows stay
|
||||
# "building", the state committed before the broadcast, and the
|
||||
# reconciler resolves them against the chain.
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"could not release the failed withdrawal %s: database busy, left to the reconciler", built.txid
|
||||
)
|
||||
raise WithdrawalError("broadcast_failed", f"the network refused the transaction: {verdict.reason}")
|
||||
if verdict.outcome is BroadcastOutcome.UNCERTAIN:
|
||||
logger.warning(
|
||||
"withdrawal %s: broadcast outcome uncertain (%s), left 'building' for the reconciler",
|
||||
built.txid,
|
||||
verdict.reason,
|
||||
)
|
||||
await session.refresh(withdrawal)
|
||||
broadcaster.publish()
|
||||
return withdrawal
|
||||
|
||||
# B-88: after the broadcast nothing is undone. Without the lock the rows stay
|
||||
# "building" and the reconciler promotes them; the caller sees the real status.
|
||||
try:
|
||||
await begin_immediate(session)
|
||||
except OperationalError:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"withdrawal %s broadcast but the database is busy: left 'building' for the reconciler", built.txid
|
||||
)
|
||||
await session.refresh(withdrawal)
|
||||
return withdrawal
|
||||
withdrawal.status = "broadcast"
|
||||
pending.status = "pending"
|
||||
await write_audit_log(
|
||||
@@ -155,6 +236,7 @@ async def _release_failed_withdrawal(
|
||||
The Withdrawal row is kept (marked "failed") rather than deleted: unlike a bet, a
|
||||
withdrawal is an instruction the user gave, and they should be able to see that it
|
||||
didn't go through."""
|
||||
await begin_immediate(session) # B-88
|
||||
for spent in built.spent_utxos:
|
||||
row = await session.scalar(
|
||||
select(UtxoEvent).where(UtxoEvent.txid == spent.txid, UtxoEvent.vout == spent.vout)
|
||||
@@ -165,6 +247,7 @@ async def _release_failed_withdrawal(
|
||||
withdrawal.txid = None
|
||||
pending.status = "failed"
|
||||
pending.failure_reason = reason[:128]
|
||||
pending.failed_at = datetime.now(timezone.utc) # B-78 (DAT-36): starts the 14-day watch
|
||||
await recompute_balance(session, user_id)
|
||||
await write_audit_log(
|
||||
session,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Documentazione di PLM Lottery
|
||||
|
||||
Questa cartella è la **fonte di verità** del progetto: si lavora **docs-first**
|
||||
(prima si scrive o si modifica la documentazione, poi il codice) e ogni
|
||||
informazione ha **un solo proprietario** (SSOT). Questo file è l'indice: da
|
||||
qui si raggiunge tutto il resto.
|
||||
|
||||
## I tre principi
|
||||
|
||||
1. **Docs-first.** Nessuna modifica di comportamento parte dal codice. Si
|
||||
modifica il requisito (o si scrive l'ADR, se è una scelta nuova), poi si
|
||||
implementa, poi si testa. Spec, codice e test entrano nello stesso commit.
|
||||
2. **SSOT — un solo proprietario per ogni informazione.** Il *perché* di una
|
||||
scelta sta in un ADR; il *cosa* verificabile in un requisito
|
||||
(`PREFISSO-NN`) del documento di dominio; il *come* nel codice; la cronologia
|
||||
dei difetti in `storico-bug.md`. Gli altri documenti **rimandano all'ID**,
|
||||
non ripetono il testo. Se due documenti dicono cose diverse, vince la
|
||||
specifica e si corregge l'altro.
|
||||
3. **Un ADR per ogni funzionalità.** Ogni funzionalità nuova inizia con un ADR
|
||||
in [adr/](adr/README.md) (modello: [adr/TEMPLATE.md](adr/TEMPLATE.md)) che
|
||||
registra contesto, decisione, alternative scartate e conseguenze, e ne
|
||||
elenca i requisiti.
|
||||
|
||||
## Dove si trova cosa
|
||||
|
||||
| Domanda | Dove |
|
||||
|---|---|
|
||||
| Perché è fatto così? Quali alternative sono state scartate? | [adr/](adr/README.md) — indice per area e tabella funzionalità → ADR |
|
||||
| Cosa deve fare esattamente il software? | I documenti di dominio qui sotto (requisiti con ID stabile) |
|
||||
| Quali limiti abbiamo accettato? | [limiti-noti.md](limiti-noti.md) (`LIM-nn`) |
|
||||
| Che bug sono stati trovati/corretti? Quali restano aperti? | [storico-bug.md](storico-bug.md); i soli aperti in [../BUGS.md](../BUGS.md) |
|
||||
| In che ordine si implementano le correzioni aperte? | [piano-correzioni.md](piano-correzioni.md) |
|
||||
| Come si modifica la documentazione/il codice? | [processo-di-modifica.md](processo-di-modifica.md) |
|
||||
| Come si installa, si avvia, si usa? | Guide operative qui sotto |
|
||||
|
||||
## Specifica (documenti di dominio)
|
||||
|
||||
Punto d'ingresso narrativo: [come-funziona.md](come-funziona.md).
|
||||
|
||||
| Documento | Prefisso | Contenuto |
|
||||
|---|---|---|
|
||||
| [registrazione-e-depositi.md](registrazione-e-depositi.md) | `REG`, `DEP` | Account, indirizzi, depositi, saldo |
|
||||
| [scommesse-e-prelievi.md](scommesse-e-prelievi.md) | `BET`, `WDR` | Scommesse e prelievi |
|
||||
| [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) | `RND` | Apertura, chiusura, estrazione, pagamento |
|
||||
| [transazioni-e-riconciliazione.md](transazioni-e-riconciliazione.md) | `TX` | Ciclo di vita delle transazioni, RBF, riconciliazione |
|
||||
| [connessione-electrum.md](connessione-electrum.md) | `ELC` | Accesso alla rete PLM e difese |
|
||||
| [architettura.md](architettura.md) | `ARC` | Struttura del codice, processi, frontend, test |
|
||||
| [modello-dati.md](modello-dati.md) | `DAT` | Tabelle, campi, stati, vincoli |
|
||||
| [api.md](api.md) | `API` | Endpoint, risposte, codici di errore |
|
||||
| [sicurezza.md](sicurezza.md) | `SEC` | Fiducia, chiavi, autenticazione, limiti sui tentativi |
|
||||
| [audit-log.md](audit-log.md) | `AUD` | Catalogo degli eventi di audit |
|
||||
| [operativita.md](operativita.md) | `OPS` | Backup, riavvii, incidenti |
|
||||
|
||||
## Decisioni, limiti, storia
|
||||
|
||||
| Documento | Prefisso | Contenuto |
|
||||
|---|---|---|
|
||||
| [adr/](adr/README.md) | `ADR` | Una decisione di progetto per file (53 al momento) |
|
||||
| [limiti-noti.md](limiti-noti.md) | `LIM` | Limiti accettati consapevolmente |
|
||||
| [storico-bug.md](storico-bug.md) | `B-nn` | Tutti i bug trovati e corretti |
|
||||
| [piano-correzioni.md](piano-correzioni.md) | — | Ordine di implementazione delle correzioni aperte |
|
||||
|
||||
## Guide operative (senza requisiti)
|
||||
|
||||
[setup.md](setup.md) · [running-the-server.md](running-the-server.md) ·
|
||||
[guida-utente.md](guida-utente.md) · [guida-admin.md](guida-admin.md). Si
|
||||
aggiornano quando la specifica cambia qualcosa di visibile.
|
||||
|
||||
## Lingua
|
||||
|
||||
La documentazione di `docs/` è in italiano, per scelta esplicita; tutto il
|
||||
resto del repository (codice, commenti, commit, `CLAUDE.md`, `BUGS.md`) è in
|
||||
inglese.
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-01 — La piattaforma è custodiale: tutte le chiavi sono generate e tenute dal server
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
||||
- **Area:** Custodia, chiavi e indirizzi
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**La piattaforma è custodiale: tutte le chiavi sono generate e
|
||||
tenute dal server.** Una sola master xprv, cifrata con Fernet a riposo; la
|
||||
chiave di cifratura sta in un file montato nel container
|
||||
(`XPRV_ENCRYPTION_KEY_FILE`, permessi `0600`, vedi
|
||||
[architettura.md](../architettura.md#configurazione)), mai nel database né in
|
||||
git. L'utente non controlla nulla finché non preleva.
|
||||
- *Motivazione:* un'esperienza da «conto» (registrazione con username e
|
||||
password, scommessa con un clic) senza portafoglio lato utente, firme o
|
||||
PSBT da gestire nel browser.
|
||||
- *Alternative scartate:* portafoglio non custodiale con firma lato client.
|
||||
- *Conseguenze:* la compromissione del server (file cifrato + variabile
|
||||
d'ambiente) espone tutti i fondi; da qui l'esportazione WIF accettata
|
||||
([ADR-39](ADR-39-l-esportazione-chiave-privata-wif-utente.md)), l'assenza di recupero password ([ADR-37](ADR-37-nessun-recupero-password-reset.md)) e il modello di
|
||||
minaccia di [sicurezza.md](../sicurezza.md).
|
||||
|
||||
*Codice:* `app/wallet/hd.py:_load_account_key` · `app/wallet/keystore.py:decrypt_xprv` · `app/wallet/hd.py:generate_master_key` ·
|
||||
*Test:* `tests/unit/test_hd.py`
|
||||
*Stato:* da implementare (B-91) · *Oggi:* la chiave di cifratura è la
|
||||
variabile d'ambiente `XPRV_ENCRYPTION_KEY`, visibile con `docker inspect`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-02 — Un indirizzo per utente, permanente, per tutti gli usi
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Custodia, chiavi e indirizzi
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Un indirizzo per utente, permanente, per tutti gli usi.**
|
||||
Alla registrazione il server deriva un indirizzo P2WPKH BIP84
|
||||
(`m/84'/746'/0'/0/<indice>`, un indice per utente, assegnato come massimo
|
||||
esistente + 1). Lo stesso indirizzo è indirizzo di deposito, riceve le
|
||||
vincite e riceve il resto di ogni scommessa e prelievo. Non esiste un
|
||||
«indirizzo vincitore» separato.
|
||||
- *Motivazione:* un solo indirizzo da osservare per utente, un solo saldo da
|
||||
ricostruire (`utxo_events`), e le vincite accreditate dal normale percorso
|
||||
dei depositi senza logica dedicata.
|
||||
- *Conseguenze:* nessuna privacy fra depositi, vincite e resti; un prelievo
|
||||
verso il proprio indirizzo è rifiutato (renderebbe indistinguibili
|
||||
destinatario e resto); il pagamento di un round si considera accreditato
|
||||
quando il listener vede la moneta sull'indirizzo del vincitore
|
||||
(vedi [registrazione-e-depositi.md](../registrazione-e-depositi.md)).
|
||||
|
||||
*Codice:* `app/auth/routes.py:register` · `app/wallet/hd.py:derive_user_address` · `app/rounds/confirmation.py:_on_payout_confirmed` ·
|
||||
*Test:* `tests/unit/test_hd.py`, `tests/unit/test_users.py`
|
||||
@@ -0,0 +1,36 @@
|
||||
# ADR-03 — Ogni round ha il proprio indirizzo del montepremi
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Custodia, chiavi e indirizzi
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Ogni round ha il proprio indirizzo del montepremi**
|
||||
(decisione del 2026-09-28), derivato dalla stessa master key sul ramo
|
||||
`m/84'/746'/0'/2/<round_id>` e salvato sulla riga del round
|
||||
(`rounds.pool_address`). Le scommesse di un round pagano solo il suo
|
||||
indirizzo e il pagamento spende solo le monete di quell'indirizzo. Il vecchio
|
||||
indirizzo unico `m/84'/746'/0'/1/0` diventa l'**indirizzo di riserva** del
|
||||
montepremi, che riceve la riserva per l'RBF di ogni pagamento ([ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md));
|
||||
i round aperti prima della modifica mantengono quell'indirizzo come
|
||||
montepremi. Un prelievo verso un indirizzo del montepremi, di riserva o delle
|
||||
commissioni è rifiutato. Il ramo `0` resta riservato agli utenti.
|
||||
- *Motivazione:* con un indirizzo unico la cronologia cresce senza limite
|
||||
(e con essa le risposte del server, B-75), il denaro di round diversi e le
|
||||
monete estranee si mescolano, e un backup ripristinato ripete un round
|
||||
contro ciò che il montepremi contiene oggi (B-90). Separare gli indirizzi
|
||||
isola denaro, cronologia e pagamento di ogni round.
|
||||
- *Alternative scartate:* un indirizzo unico con contabilità per round nel
|
||||
database (non limita la cronologia e non isola le monete estranee);
|
||||
consolidamento periodico dell'indirizzo unico (sposta il problema, non lo
|
||||
toglie).
|
||||
- *Conseguenze:* nessun segreto aggiuntivo (tutto deriva dalla master
|
||||
key); una moneta estranea inviata all'indirizzo di un round chiuso resta
|
||||
lì, visibile ma fuori da ogni pagamento; il margine fra partecipanti e
|
||||
ingressi del pagamento ([ADR-21](ADR-21-tre-limiti-distinti-sul-numero-monete.md)) non si consuma più nel tempo ([LIM-17]).
|
||||
Regole in [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
|
||||
e [scommesse-e-prelievi.md](../scommesse-e-prelievi.md#prelievo).
|
||||
|
||||
*Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · `app/wallet/hd.py:derive_round_pool_key` · `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* `tests/unit/test_round_pool_address.py`, `tests/unit/test_pending_address.py`
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-04 — Accesso alla rete solo via protocollo Electrum
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Custodia, chiavi e indirizzi
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Accesso alla rete solo via protocollo Electrum**, senza nodo
|
||||
completo né P2P. Una sola connessione attiva serve tutto (accrediti, invii,
|
||||
conferme, altezza della catena).
|
||||
- *Motivazione:* nessun nodo da installare e sincronizzare.
|
||||
- *Conseguenze:* la connessione è il principale punto singolo di guasto e il
|
||||
principale rischio di integrità; da qui le difese di [ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md),
|
||||
[ADR-26](ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md) e [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e i limiti [LIM-11], [LIM-12], [LIM-14]. Dettagli in
|
||||
[connessione-electrum.md](../connessione-electrum.md).
|
||||
|
||||
*Codice:* `app/electrum/client.py:ElectrumClient` · `app/electrum/listener.py:ElectrumListener` · `app/main.py:lifespan`
|
||||
@@ -0,0 +1,17 @@
|
||||
# ADR-05 — Solo indirizzi bech32 PLM (`plm1…`) come destinazione
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Custodia, chiavi e indirizzi
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Solo indirizzi bech32 PLM (`plm1…`) come destinazione.** Un
|
||||
prelievo verso un indirizzo legacy (`P…`) o di un'altra catena (`bc1…`) è
|
||||
rifiutato; lo stesso controllo vale per `fee_address` e per `/qr/{address}`.
|
||||
- *Motivazione:* un indirizzo di un'altra catena si interpreta comunque come
|
||||
witness program valido, quindi la transazione si costruirebbe e i fondi
|
||||
sarebbero irrecuperabili.
|
||||
|
||||
*Codice:* `app/wallet/address.py:is_valid_plm_address` · `app/withdrawals/service.py:request_withdrawal` · `app/api/routes/admin.py:RoundConfigUpdate` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_non_plm_address`
|
||||
@@ -0,0 +1,40 @@
|
||||
# ADR-06 — I depositi richiedono `deposit_confirmations` conferme (6 per default, configurabile in `round_config`), le uscite coinbase 120; ogni altra transazione ne richiede 1
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-82)
|
||||
- **Area:** Conferme e fiducia nella rete
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**I depositi richiedono `deposit_confirmations` conferme (6
|
||||
per default, configurabile in `round_config`), le uscite coinbase 120; ogni
|
||||
altra transazione ne richiede 1** (decisione del 2026-09-28). «Deposito» è
|
||||
una moneta arrivata all'indirizzo di un utente da una transazione che la
|
||||
piattaforma non ha costruito; le uscite delle transazioni della piattaforma
|
||||
(resto di scommesse e prelievi, vincite) si accreditano a 1 conferma come
|
||||
le transazioni che le hanno prodotte. Le soglie si cambiano solo con una
|
||||
decisione esplicita.
|
||||
- *Motivazione:* il deposito è **l'unico ingresso che un utente può spendere
|
||||
due volte**: tutto il resto lo firma solo il server. Un utente che
|
||||
scommette con un deposito a 1 conferma e poi, se perde, lo sostituisce con
|
||||
una catena privata fa sparire la propria scommessa dal montepremi mentre il
|
||||
partecipante resta contato, e il round diventa impagabile per tutti
|
||||
(B-82). La potenza di calcolo di PLM (~22 TH/s) si può affittare, quindi
|
||||
una riorganizzazione di 1–2 blocchi è alla portata di un singolo
|
||||
attaccante; 6 conferme la rendono costosa quanto sei blocchi di tutta la
|
||||
rete. È anche la soglia di spendibilità del portafoglio di riferimento
|
||||
([LIM-27]).
|
||||
- *Alternative scartate:* 1 conferma per tutto (la regola precedente,
|
||||
esposta al doppio speso del deposito); 6 conferme per tutto (allunga ogni
|
||||
round di cinque blocchi per coprire transazioni che l'utente non può
|
||||
sostituire); scommesse ammesse solo da monete a 6 conferme ma depositi
|
||||
mostrati prima (due saldi da spiegare all'utente per lo stesso rischio).
|
||||
- *Conseguenze:* un deposito diventa spendibile dopo 6 blocchi (12 minuti
|
||||
in media, con blocchi da 120s) invece di 1; il rischio residuo (una riorganizzazione più profonda di 6 blocchi)
|
||||
è contenuto da [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e accettato in [LIM-24]. Regole in
|
||||
[registrazione-e-depositi.md](../registrazione-e-depositi.md#deposito) (B-82).
|
||||
|
||||
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ·
|
||||
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* ogni moneta, deposito o coinbase
|
||||
compresi, si accredita a 1 conferma.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ADR-07 — Ogni decisione che sposta denaro in base a una risposta di rete è corroborata da un quorum degli altri server configurati
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Conferme e fiducia nella rete
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Ogni decisione che sposta denaro in base a una risposta di
|
||||
rete è corroborata da un quorum degli altri server configurati**: il blocco
|
||||
che semina l'estrazione, una moneta data per spesa all'esterno, un nuovo
|
||||
deposito da accreditare, il ripristino di una moneta, la conferma di una
|
||||
transazione (con prova merkle contro un header che possediamo), l'abbandono
|
||||
di una transazione, le monete del round verificate prima del pagamento. Si
|
||||
interrogano solo gli *altri* server, mai quello attivo (che è ciò che un
|
||||
attaccante controllerebbe); servono **almeno 2 risposte** e la maggioranza
|
||||
stretta di chi risponde. Il server rifiuta di partire con meno di 2 server
|
||||
di riserva, salvo `ALLOW_NO_QUORUM=true` (solo sviluppo). Se rispondono meno
|
||||
di 2 server, la corroborazione fallisce.
|
||||
- *Motivazione:* un solo server ostile non deve poter scegliere il
|
||||
vincitore (B-28), azzerare un saldo (B-29) o gonfiarlo (B-59), né far
|
||||
credere confermato un pagamento mai trasmesso (B-83). I saldi si muovono in
|
||||
entrambe le direzioni, quindi entrambe richiedono il quorum. Con 0 o 1
|
||||
server di riserva la «maggioranza» è una sola voce, o nessuna (B-84).
|
||||
- *Alternative scartate:* quorum facoltativo, che passa sempre con la lista
|
||||
vuota (la regola precedente: il default di `.env.example` spegneva ogni
|
||||
difesa senza avvisare).
|
||||
- *Conseguenze:* una corroborazione fallita **ritarda**, non perde e non
|
||||
libera fondi ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)): il deposito è riproposto al refresh successivo,
|
||||
l'estrazione richiede di nuovo lo stesso blocco. Il quorum resta fra server
|
||||
scelti dall'operatore ([LIM-11]). Regole in
|
||||
[connessione-electrum.md](../connessione-electrum.md#5-il-quorum) (B-83, B-84).
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority` · `app/electrum/listener.py:ElectrumListener.agreed_block_hash` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit` · `app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement` · `app/electrum/listener.py:ElectrumListener.confirmation_proven` · `app/main.py:lifespan` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-08 — I parametri operativi stanno nel database, i segreti nell'ambiente
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**I parametri operativi stanno nel database, i segreti
|
||||
nell'ambiente.** Ogni parametro di business (costo della scommessa, durata e
|
||||
pausa del round, animazione, fee rate, timeout RBF, `fee_address`, pausa) è
|
||||
una colonna dell'unica riga di `round_config`, modificabile a caldo da
|
||||
`/admin`; i valori iniziali sono i default delle colonne di `RoundConfig`,
|
||||
non `app/config.py`. In `.env` restano solo segreti e collegamenti
|
||||
(master key, JWT, host Electrum, token admin, URL del database).
|
||||
- *Motivazione:* regolare la lotteria senza ridistribuire né riavviare;
|
||||
i segreti richiedono comunque un riavvio e non vanno scambiati a caldo.
|
||||
- *Conseguenze:* ogni modifica è validata con limiti espliciti ([ADR-09](ADR-09-modifiche-configurazione-validate-fidate.md)) e
|
||||
registrata in audit come `config_updated` con prima/dopo.
|
||||
|
||||
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` · `app/config.py:Settings` · `app/api/routes/admin.py:update_config` ·
|
||||
*Test:* `tests/unit/test_admin.py::test_admin_reads_and_updates_config`, `tests/unit/test_admin.py::test_config_update_is_audit_logged`
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-09 — Le modifiche di configurazione sono validate, non fidate
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Le modifiche di configurazione sono validate, non fidate.**
|
||||
Ogni campo numerico ha limiti (ad esempio `fee_rate_sat_vb` fra 1 e
|
||||
`MAX_FEE_RATE_SAT_VB`, `round_duration_seconds` almeno 30) e `fee_address`
|
||||
deve essere un indirizzo PLM valido. `paused` non è modificabile da
|
||||
`PUT /admin/config`.
|
||||
- *Motivazione:* un fee rate a 0 produce transazioni che nessun nodo inoltra,
|
||||
una durata a 0 fa scadere il round appena aperto, un `fee_address` estraneo
|
||||
manda la commissione in uno script irrecuperabile (B-05).
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:RoundConfigUpdate` ·
|
||||
*Test:* `tests/unit/test_admin.py::test_config_rejects_unusable_values`, `tests/unit/test_admin.py::test_pause_cannot_be_toggled_through_the_config_endpoint`
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-10 — I parametri del round sono fotografati all'apertura
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-95)
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**I parametri del round sono fotografati all'apertura.**
|
||||
Durata, pausa successiva, costo della scommessa (`bet_amount_sats`),
|
||||
`fee_address` e `draw_confirmations` vengono copiati sulla riga del round
|
||||
quando si apre; una modifica vale dal round successivo, mai per quello in
|
||||
corso. La pausa fra due round è quella annunciata dal round appena chiuso.
|
||||
Il fee rate resta letto dal vivo: riguarda la singola transazione, non le
|
||||
regole del round.
|
||||
- *Motivazione:* abbassare la durata a metà round lo chiudeva all'istante e
|
||||
spostava il conto alla rovescia di tutti i client (B-61); cambiare costo o
|
||||
`fee_address` a metà round creava poste diverse con la stessa probabilità
|
||||
e deviava la commissione del round in corso (B-95).
|
||||
- *Conseguenze:* il prelievo minimo ([ADR-20](ADR-20-prelievo-minimo-costo-corrente-scommessa.md)) segue il costo corrente della
|
||||
configurazione, non quello del round in corso. Regole in
|
||||
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#0--apertura) (B-95).
|
||||
|
||||
*Codice:* `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:round_deadline` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py::test_a_new_round_snapshots_the_current_config_timing`, `tests/unit/test_scheduler.py::test_tick_ignores_a_config_duration_edited_mid_round`, `tests/unit/test_rounds_service.py::test_cooldown_comes_from_the_round_that_closed`
|
||||
*Stato:* da implementare (B-95) · *Oggi:* sono fotografati solo durata e
|
||||
pausa; costo, `fee_address` e fee rate sono letti dal vivo ([LIM-16]).
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-11 — Il server rifiuta di partire con segreti inadeguati
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-91)
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il server rifiuta di partire con segreti inadeguati**:
|
||||
`JWT_SECRET` sotto i 32 caratteri o `XPRV_ENCRYPTION_KEY` vuota fermano
|
||||
l'avvio. Un `ADMIN_TOKEN` vuoto invece non è fatale: il pannello resta
|
||||
chiuso a tutti. Il controllo è nel lifespan, non in un validatore di
|
||||
`Settings`.
|
||||
- *Motivazione:* evitare un container che sembra sano e si rompe al primo
|
||||
utente reale; tenerlo fuori da `Settings` permette a import e test di
|
||||
funzionare senza `.env`.
|
||||
- *Alternative scartate:* validatore Pydantic su `Settings` (avrebbe fatto
|
||||
fallire la raccolta dei test su un clone pulito, B-15).
|
||||
- *Conseguenze:* lo stesso principio («rifiutare di servire piuttosto che
|
||||
rompersi al primo uso») si estende alla master key decifrata e confrontata
|
||||
con gli indirizzi già salvati (B-89) e al quorum minimo di server (B-84);
|
||||
l'ordine dei controlli all'avvio è in
|
||||
[architettura.md](../architettura.md#nel-lifespan).
|
||||
|
||||
*Codice:* `app/config.py:validate_runtime_secrets` · `app/main.py:lifespan` · `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_config.py`
|
||||
*Stato:* da implementare (B-91) per la parte sulla chiave · *Oggi:* la
|
||||
chiave Fernet arriva da `XPRV_ENCRYPTION_KEY` (variabile d'ambiente); dopo
|
||||
B-91 arriva da un file (`XPRV_ENCRYPTION_KEY_FILE`,
|
||||
[sicurezza.md](../sicurezza.md#sec-53) SEC-53) e il rifiuto riguarda un file
|
||||
mancante o vuoto.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-12 — Il server gira solo in Docker, in sviluppo come in produzione
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-81)
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il server gira solo in Docker, in sviluppo come in
|
||||
produzione**, dietro Caddy (TLS automatico, certificato autofirmato su
|
||||
`localhost`). Il container rifiuta di partire senza il file della master
|
||||
key, applica le migrazioni Alembic all'avvio e si riavvia da solo
|
||||
(`restart: unless-stopped`). Il venv serve solo per test, migrazioni e
|
||||
script della chiave.
|
||||
- *Motivazione:* un solo modo di eseguire il server, identico ovunque.
|
||||
- *Conseguenze:* dopo un crash ogni stato del round riprende da solo,
|
||||
`drawing` compreso ([ADR-25](ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md), B-81). Procedure in
|
||||
[operativita.md](../operativita.md).
|
||||
|
||||
*Codice:* `Dockerfile` · `docker-compose.yml` · `Caddyfile` ·
|
||||
*Test:* `tests/unit/test_docs_no_bare_uvicorn.py`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* dopo un crash `closing` e
|
||||
`paying_out` riprendono da soli, `drawing` no ([LIM-01]).
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-13 — Un solo processo, un solo file SQLite
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Configurazione e ambiente
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Un solo processo, un solo file SQLite** (modalità WAL con
|
||||
`busy_timeout`, `synchronous=FULL`, `BEGIN IMMEDIATE` per ogni transazione
|
||||
che sposta denaro, vedi [architettura.md](../architettura.md#database), B-88).
|
||||
Canale SSE, blocchi per-utente, limitatori di tentativi e cache sono in
|
||||
memoria.
|
||||
- *Motivazione:* semplicità a scala di ~100 utenti concorrenti.
|
||||
- *Alternative scartate:* Redis o un database con lock condivisi, da non
|
||||
introdurre in via speculativa.
|
||||
- *Conseguenze:* nessuna distribuzione su più worker ([LIM-18]). L'unico
|
||||
invariante che non dipende dal processo singolo è «al più un round
|
||||
attivo», garantito da un indice ([ADR-22](ADR-22-piu-round-attivo-come-invariante-database.md)).
|
||||
|
||||
*Codice:* `app/db/base.py:_register_sqlite_pragmas` · `app/db/base.py:begin_immediate` · `app/tx/locks.py:UserLocks` · `app/rounds/events.py:RoundEventBroadcaster` ·
|
||||
*Test:* `tests/unit/test_db_base.py` · `tests/unit/test_sqlite_transactions.py`
|
||||
@@ -0,0 +1,17 @@
|
||||
# ADR-14 — Ripartizione fissa 70% al vincitore / 30% alla piattaforma
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Ripartizione fissa 70% al vincitore / 30% alla piattaforma**,
|
||||
con l'arrotondamento a favore delle commissioni. È nel codice, non nella
|
||||
configurazione: cambiarla è una modifica del software.
|
||||
- *Motivazione:* una regola economica di fondo non deve poter cambiare con un
|
||||
clic dal pannello. Un'unica funzione alimenta sia il montepremi mostrato sia
|
||||
il pagamento, così i due non possono divergere.
|
||||
|
||||
*Codice:* `app/rounds/service.py:winner_share` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/api/routes/rounds.py:current_round` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-15 — Biglietto a prezzo fisso, una scommessa per utente per round, probabilità uguale per tutti
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Biglietto a prezzo fisso, una scommessa per utente per round,
|
||||
probabilità uguale per tutti** indipendentemente dall'importo effettivo.
|
||||
Nessun deposito minimo.
|
||||
- *Conseguenze:* l'estrazione sceglie fra partecipanti, non pesa gli
|
||||
importi. Il costo è fotografato all'apertura del round ([ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), così
|
||||
tutti i partecipanti di un round pagano la stessa posta (B-95; oggi è letto
|
||||
dal vivo, [LIM-16]).
|
||||
|
||||
*Codice:* `app/bets/service.py:place_bet` · `app/rounds/draw.py:draw_winner` ·
|
||||
*Test:* `tests/unit/test_bets.py::test_place_bet_rejects_second_bet_same_round`
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-16 — La commissione di rete si toglie dall'importo che si muove
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**La commissione di rete si toglie dall'importo che si
|
||||
muove**, non si aggiunge sopra: per una scommessa arriva al montepremi il
|
||||
costo meno la commissione, per un prelievo arriva a destinazione l'importo
|
||||
meno la commissione.
|
||||
- *Conseguenze:* il montepremi si calcola sommando gli importi effettivi
|
||||
delle scommesse, non moltiplicando il numero di giocatori per il costo
|
||||
(B-11); il prelievo registra sia l'importo richiesto sia quello inviato.
|
||||
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_deducts_fee_from_amount_not_change`
|
||||
@@ -0,0 +1,35 @@
|
||||
# ADR-17 — La commissione del pagamento esce dalla quota del vincitore; la riserva per l'RBF esce dalla quota della piattaforma
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-79)
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**La commissione del pagamento esce dalla quota del
|
||||
vincitore; la riserva per l'RBF esce dalla quota della piattaforma.** Ogni
|
||||
pagamento ha un'uscita di **riserva** di
|
||||
`max(DUST_LIMIT_SATS, dimensione stimata × 10 sat/vB)` verso l'indirizzo di
|
||||
riserva del montepremi ([ADR-03](ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md)); un'accelerazione riduce quell'uscita. La
|
||||
commissione verso `fee_address` è quindi il 30% meno la riserva.
|
||||
- *Motivazione:* il pagamento spende di norma tutte e sole le monete del
|
||||
round, quindi non ha resto e non si potrebbe mai accelerare: un pagamento
|
||||
fermo in mempool terrebbe il round in `paying_out` per sempre (B-79). La
|
||||
riserva la paga la piattaforma perché è la piattaforma a scegliere il fee
|
||||
rate.
|
||||
- *Alternative scartate:* riserva presa dalla quota del vincitore (il
|
||||
montepremi annunciato scenderebbe ancora); CPFP da un'uscita del vincitore
|
||||
(richiede una seconda transazione e una moneta del vincitore); pagamento
|
||||
senza riserva e abbandono dopo un timeout (vietato da [ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)).
|
||||
- *Conseguenze:* il montepremi annunciato (il 70% delle scommesse
|
||||
confermate) supera di poco quanto il vincitore riceve davvero; la
|
||||
differenza, la commissione del pagamento, non è nota finché il pagamento non
|
||||
è costruito. [ADR-29](ADR-29-senza-fee-address-apre-nessun-round.md) rifiuta di aprire round con una configurazione in cui
|
||||
un round da un partecipante non coprirebbe commissione del vincitore,
|
||||
riserva e una commissione sopra la soglia di polvere. Regole in
|
||||
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento) (B-79).
|
||||
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`
|
||||
*Stato:* da implementare (B-79) · *Oggi:* nessuna riserva: il 30% va intero a
|
||||
`fee_address` e, nel caso normale, il pagamento non ha resto.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-18 — L'accelerazione RBF la paga chi riceve il resto
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**L'accelerazione RBF la paga chi riceve il resto**: l'utente
|
||||
per scommesse e prelievi, la piattaforma (tramite la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md)) per
|
||||
i pagamenti. Le uscite verso la controparte non si toccano mai. La regola,
|
||||
con i casi in cui il resto non basta, è di
|
||||
[TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta).
|
||||
- *Motivazione:* chi riceve il resto è chi ha scelto di spendere; ridurre
|
||||
l'uscita del destinatario cambierebbe l'importo che la controparte si
|
||||
aspetta (B-17).
|
||||
- *Conseguenze:* ogni transazione deve avere un'uscita che il mittente può
|
||||
ridurre: da qui [ADR-19](ADR-19-ogni-transazione-utente-conserva-uscita-resto.md) per le transazioni utente e la riserva di
|
||||
[ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md) per i pagamenti ([LIM-02]).
|
||||
|
||||
*Codice:* `app/tx/broadcast.py:bump_fee` · `app/tx/broadcast.py:_find_change_output` ·
|
||||
*Test:* `tests/unit/test_broadcast.py`
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-19 — Ogni transazione utente conserva un'uscita di resto verso l'utente, e un'accelerazione che porterebbe quel resto sotto `DUST_LIMIT_SATS` (294 sat) lo elimina
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-87)
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Ogni transazione utente conserva un'uscita di resto verso
|
||||
l'utente, e un'accelerazione che porterebbe quel resto sotto
|
||||
`DUST_LIMIT_SATS` (294 sat) lo elimina.** Le regole sono di
|
||||
[BET-14](../scommesse-e-prelievi.md#cosa-succede) e
|
||||
[WDR-09](../scommesse-e-prelievi.md#prelievo) (resto minimo alla costruzione)
|
||||
e di [TX-11](../transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)
|
||||
(resto eliminato all'accelerazione, B-87).
|
||||
- *Motivazione:* un prelievo totale produceva una transazione a un'uscita
|
||||
che nessun RBF poteva salvare (B-62); aggiungere monete non avrebbe
|
||||
aiutato, perché la transazione le spendeva già tutte. Il resto minimo di
|
||||
294 sat però **non basta a restare accelerabile**: il primo aumento
|
||||
minimo (1 sat/vB sull'intera transazione, 141 sat per un ingresso e due
|
||||
uscite) lo porta a 153 sat, sotto la soglia di polvere, e ogni sostituzione
|
||||
successiva viene rifiutata dalle regole di inoltro (B-87). Garantisce
|
||||
l'esistenza dell'uscita, non la capacità di assorbire aumenti.
|
||||
- *Alternative scartate:* un resto minimo più grande (sottrae all'utente un
|
||||
importo che deve comunque poter prelevare, e sposta solo la soglia);
|
||||
aggiungere ingressi alla sostituzione (nel caso che conta, il prelievo
|
||||
totale, non ce ne sono altri).
|
||||
- *Conseguenze:* quando il resto scende sotto soglia, il suo valore va in
|
||||
commissione e la sostituzione è l'ultima possibile per quella transazione;
|
||||
l'utente perde al più 294 sat. **Il pagamento del round** non ha un resto
|
||||
utente: la sua uscita riducibile è la riserva di [ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md).
|
||||
|
||||
*Codice:* `app/wallet/psbt_builder.py:build_signed_transaction` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py::test_build_signed_transaction_refuses_an_amount_that_would_leave_no_change`, `tests/unit/test_withdrawals.py::test_full_balance_withdrawal_keeps_a_bumpable_change_output`, `tests/unit/test_withdrawals.py::test_a_bet_from_a_balance_equal_to_the_bet_is_refused`
|
||||
*Stato:* da implementare (B-87) · *Oggi:* `bump_fee` richiede solo che il
|
||||
resto superi l'aumento, e può lasciare un resto sotto soglia che nessun nodo
|
||||
inoltra.
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-20 — Il prelievo minimo è il costo corrente della scommessa
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il prelievo minimo è il costo corrente della scommessa**, senza
|
||||
un campo di configurazione separato.
|
||||
- *Motivazione:* un parametro in meno da tenere coerente.
|
||||
- *Conseguenze:* alzare il costo della scommessa alza anche il prelievo
|
||||
minimo, con effetto immediato.
|
||||
|
||||
*Codice:* `app/withdrawals/service.py:request_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_rejects_amount_below_minimum`
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR-21 — Tre limiti distinti sul numero di monete e di giocatori:
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Economia del gioco
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Tre limiti distinti sul numero di monete e di giocatori:**
|
||||
|
||||
| Costante | Valore | Protegge da |
|
||||
|---|---|---|
|
||||
| `MAX_TX_INPUTS` | 50 | una transazione utente (scommessa, prelievo) che erode l'importo in commissioni per un indirizzo troppo frammentato; oltre, `too_many_inputs` |
|
||||
| `MAX_PAYOUT_TX_INPUTS` | 500 | un pagamento troppo grande per le regole di inoltro (~1470 ingressi a 100 kvB); un terzo del margine |
|
||||
| `MAX_PARTICIPANTS_PER_ROUND` | 400 | un round impagabile: il 401° giocatore è rifiutato con `round_full` **prima** che si muova denaro |
|
||||
|
||||
- *Motivazione:* il pagamento spende una moneta del montepremi per scommessa;
|
||||
usare per esso il limite utente rendeva impagabile ogni round oltre ~50
|
||||
giocatori e fermava la lotteria (B-52). Con un indirizzo per round
|
||||
([ADR-03](ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md), B-80) il pagamento spende solo le monete del round, quindi i 100
|
||||
di differenza fra 400 e 500 restano un margine fisso invece di consumarsi
|
||||
con le monete avanzate dai round precedenti (come accade oggi con
|
||||
l'indirizzo unico).
|
||||
- *Conseguenze:* «ogni round si può pagare» è un invariante verificato
|
||||
all'ingresso, non una scoperta al momento del pagamento. Il conteggio dei
|
||||
partecipanti include quelli non ancora confermati (direzione prudente). Un
|
||||
tetto opzionale al montepremi del round (`max_round_pool_sats`, 0 =
|
||||
disattivato) rifiuta con `round_full` anche le scommesse oltre quel valore
|
||||
(B-95); se e come legarlo al costo di un attacco al 51% è una decisione
|
||||
non ancora presa ([LIM-24]).
|
||||
|
||||
*Codice:* `app/wallet/psbt_builder.py:select_utxos` · `app/bets/service.py:place_bet` · `app/wallet/psbt_builder.py:build_payout_transaction` ·
|
||||
*Test:* `tests/unit/test_psbt_builder.py::test_select_utxos_never_exceeds_the_input_cap`, `tests/unit/test_bets.py::test_place_bet_rejects_the_bet_past_the_participant_cap`, `tests/unit/test_payout_builder.py::test_payout_at_the_participant_cap_stays_well_inside_relay_limits`
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-22 — Al più un round attivo, come invariante del database
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Al più un round attivo, come invariante del database.** Un
|
||||
indice unico su un'espressione costante, limitato agli stati attivi, fa
|
||||
fallire in modo pulito il secondo inserimento concorrente; chi perde adotta
|
||||
il round del vincitore (al massimo 3 tentativi). I round non si
|
||||
sovrappongono mai: il successivo si apre solo quando il precedente è
|
||||
`closed`.
|
||||
- *Alternative scartate:* rendere lo scheduler l'unico autore dei round (la
|
||||
prima scommessa dopo una pausa non avrebbe potuto aprirne uno, B-09).
|
||||
|
||||
*Codice:* `app/db/models.py:Round` · `app/rounds/service.py:open_new_round_if_needed` · `app/rounds/service.py:get_active_round` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py::test_the_database_refuses_a_second_active_round`, `tests/unit/test_rounds_service.py::test_losing_the_open_race_reuses_the_winning_round`
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-23 — Il limite per scommettere è la scadenza del round stesso, non il suo stato nel database
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il limite per scommettere è la scadenza del round stesso, non
|
||||
il suo stato nel database**, e la chiusura «a semaforo giallo» aspetta che
|
||||
ogni scommessa già inviata sia confermata prima di estrarre. L'iscrizione è
|
||||
salvata con un compare-and-set sulla riga del round.
|
||||
- *Motivazione:* lo scheduler controlla ogni 5s, quindi lo stato può restare
|
||||
`open` oltre la scadenza; senza il compare-and-set una scommessa poteva
|
||||
finire nel montepremi ed essere esclusa dall'estrazione (B-53).
|
||||
- *Conseguenze:* l'attesa in `closing` finisce solo quando ogni scommessa è
|
||||
confermata o dimostrata morta ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)); non ha un limite di tempo
|
||||
([LIM-03]). Dettagli in
|
||||
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#2--chiusura).
|
||||
|
||||
*Codice:* `app/rounds/service.py:round_accepts_bets` · `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` ·
|
||||
*Test:* `tests/unit/test_bets.py::test_place_bet_refuses_when_the_round_closed_between_the_check_and_the_commit`, `tests/unit/test_scheduler.py::test_close_and_draw_waits_when_a_bet_appears_after_the_tick_check`
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR-24 — L'estrazione è un commit-reveal fra il server e un blocco futuro
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**L'estrazione è un commit-reveal fra il server e un blocco
|
||||
futuro** (decisione del 2026-09-28). All'apertura del round il server genera
|
||||
un segreto casuale di 32 byte (`server_seed`), lo salva e pubblica solo la
|
||||
sua impronta `server_seed_hash = sha256(server_seed)`. All'ingresso in
|
||||
`drawing` si fissano l'elenco dei partecipanti confermati, ordinato per txid
|
||||
della scommessa crescente, la sua impronta `participants_hash` (sha256 delle
|
||||
righe `txid:vout:indirizzo`) e l'altezza del blocco di estrazione
|
||||
(`draw_height`, [ADR-25](ADR-25-blocco-estrazione-fissato-altezza-successivo-chiusura.md)). Il seme è
|
||||
`sha256(server_seed || block_hash || participants_hash)` letto come intero,
|
||||
l'indice del vincitore è seme modulo numero di partecipanti. Dopo il
|
||||
salvataggio del vincitore il server rivela `server_seed` e pubblica la prova
|
||||
completa (`GET /rounds/{id}/proof`), da cui chiunque ricalcola l'esito.
|
||||
L'algoritmo resta un **componente sostituibile**: il resto del sistema non
|
||||
va architettato attorno alla sua forma, ma ogni sostituto deve mantenere le
|
||||
stesse quattro proprietà — impegno pubblicato prima delle scommesse, elenco
|
||||
dei partecipanti fissato prima del blocco, esito ricalcolabile dai dati
|
||||
pubblicati, esito non calcolabile da chi produce o trasmette i blocchi.
|
||||
- *Motivazione:* il vincitore non deve essere calcolabile da chi controlla
|
||||
la produzione dei blocchi: la potenza di calcolo di PLM (~22 TH/s) si può
|
||||
affittare. Un minatore che non conosce `server_seed` non sa chi vincerebbe
|
||||
con il suo blocco e quindi non ha motivo di scartarlo; l'operatore non
|
||||
può cambiare `server_seed` dopo averne pubblicato l'impronta e non conosce
|
||||
il blocco quando si impegna. L'ordine per txid e l'impronta dell'elenco
|
||||
rendono l'esito riproducibile e impediscono di aggiungere o togliere
|
||||
partecipanti dopo aver visto il blocco (B-73).
|
||||
- *Alternative scartate:*
|
||||
- *hash del solo blocco* (la forma precedente): un minatore con biglietti
|
||||
in gioco trattiene i blocchi finché uno lo fa vincere, e il server
|
||||
Electrum attivo sceglie quale blocco reale usare semplicemente non
|
||||
annunciandone uno;
|
||||
- *hash di più blocchi consecutivi*: chi produce l'ultimo blocco della
|
||||
serie può ancora scartarlo e riprovare, quindi resta manipolabile;
|
||||
- *segreti forniti dai giocatori* (commit-reveal fra partecipanti): chi non
|
||||
rivela blocca o condiziona l'estrazione, e servirebbe un'azione in più a
|
||||
ogni giocatore;
|
||||
- *sorgente di casualità esterna* (beacon): una dipendenza di rete in più,
|
||||
di cui fidarsi quanto del server.
|
||||
- *Conseguenze:* l'operatore, che conosce `server_seed`, può calcolare
|
||||
l'esito appena il blocco è noto: una collusione fra operatore e minatore
|
||||
resta possibile ed è accettata ([LIM-25]), perché l'operatore è già
|
||||
fidato come custode ([LIM-26]). `server_seed` non è esposto da nessun
|
||||
endpoint prima della rivelazione, e nel database è cifrato con la chiave
|
||||
della master xprv finché non viene rivelato
|
||||
([sicurezza.md](../sicurezza.md#sec-56) SEC-56): una copia del database da
|
||||
sola non basta a conoscerlo. Regole in
|
||||
[ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#3--estrazione),
|
||||
endpoint in [api.md](../api.md) (B-73).
|
||||
|
||||
*Codice:* `app/rounds/draw.py:draw_winner` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._complete_draw` · `app/rounds/service.py:open_new_round_if_needed` · `app/api/routes/rounds.py:round_proof` ·
|
||||
*Test:* `tests/unit/test_draw.py`, `tests/unit/test_draw_proof.py`
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-25 — Il blocco di estrazione è fissato per altezza, è successivo alla chiusura, e si usa solo quando è confermato e corroborato
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-81)
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il blocco di estrazione è fissato per altezza, è successivo
|
||||
alla chiusura, e si usa solo quando è confermato e corroborato.**
|
||||
All'ingresso in `drawing` si salva `draw_height = tip_at_close + 1`, che non
|
||||
cambia più. Si attende che quel blocco abbia `draw_confirmations` conferme
|
||||
(3 per default, fotografato all'apertura, [ADR-10](ADR-10-parametri-round-fotografati-all-apertura.md)), poi si chiede a tutti i
|
||||
server l'header *a quell'altezza*: serve l'accordo del quorum ([ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)); in
|
||||
caso di disaccordo si attende e si richiede la stessa altezza, mai un blocco
|
||||
diverso. L'attesa non ha timeout (l'entropia dipende davvero da un blocco
|
||||
futuro) ma è osservabile (`draw_stalled`, `draw_waiting_since`) e
|
||||
riprendibile: `draw_height`, `participants_hash` e la posizione di ogni
|
||||
partecipante sono salvati, e lo scheduler riprende un round in `drawing`
|
||||
dopo un errore o un riavvio con lo stesso esito. Un'altezza `0` alla
|
||||
chiusura significa «sconosciuta»: la prima altezza appresa fa da base.
|
||||
- *Motivazione:* un hash già pubblico mentre le scommesse erano aperte
|
||||
renderebbe il vincitore prevedibile (B-63); un header fabbricato
|
||||
sceglierebbe il vincitore (B-28); un'altezza non fissata lascia al server
|
||||
attivo la scelta di quale blocco reale usare, e passare a «un blocco
|
||||
ulteriore» dopo una corroborazione fallita gli dà un secondo tentativo
|
||||
(B-73). Le conferme sul blocco di estrazione evitano che una
|
||||
riorganizzazione cambi il seme dopo l'estrazione. Uno stato tenuto solo in
|
||||
memoria bloccava il round in `drawing` a ogni eccezione (B-81).
|
||||
- *Alternative scartate:* timeout sull'attesa con ripiego su un altro blocco
|
||||
(riaprirebbe la scelta del blocco).
|
||||
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._wait_for_draw_block` · `app/rounds/scheduler.py:RoundScheduler._close_and_draw` · `app/rounds/scheduler.py:RoundScheduler._tick` ·
|
||||
*Test:* `tests/unit/test_scheduler.py::test_a_block_relayer_cannot_choose_the_block_that_seeds_the_draw`, `tests/unit/test_scheduler.py::test_a_disagreement_asks_the_same_height_again_and_is_audited_once_per_hash`
|
||||
*Stato:* da implementare (B-81) · *Oggi:* `draw_height`, `participants_hash` e le
|
||||
posizioni sono salvati, ma `_tick` salta i round in `drawing`: dopo un errore
|
||||
o un riavvio il round non riparte.
|
||||
@@ -0,0 +1,35 @@
|
||||
# ADR-26 — Validazione degli header concatenata, senza ricalcolo della difficoltà
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Validazione degli header concatenata, senza ricalcolo della
|
||||
difficoltà.** Un header deve dichiarare un bersaglio non più facile del
|
||||
limite di rete (`powLimit`, `0x00000000ffff…`), rispettare quel bersaglio e
|
||||
concatenarsi alla catena che teniamo: su un avanzamento di più blocchi si
|
||||
scaricano gli header intermedi e si verifica tutta la catena. Il listener
|
||||
tiene gli ultimi 10 header; un header che non si concatena alla punta ma a
|
||||
uno di essi è una riorganizzazione da seguire ([ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)), non un server
|
||||
ostile. Un header senza `hex` è ignorato senza chiudere la sessione. Regole
|
||||
in [connessione-electrum.md](../connessione-electrum.md#4-header-validati-non-creduti)
|
||||
(B-74, B-85).
|
||||
- *Motivazione:* il controllo «rispetta il bersaglio che dichiara», da solo,
|
||||
**non è una difesa**: il bersaglio lo sceglie chi produce l'header, e con
|
||||
`bits=0x207fffff` un header passa senza lavoro. Un salto di più blocchi non
|
||||
collegato alla punta permetteva di fissare un'altezza enorme, che non
|
||||
regredisce mai, e congelare ogni estrazione (B-74). Trattare ogni
|
||||
riorganizzazione di un blocco come un server ostile chiudeva la sessione
|
||||
su tutti i server fino al blocco successivo.
|
||||
- *Alternative scartate:* validazione completa della catena con le regole di
|
||||
ricalcolo della difficoltà di PLM (fuori scopo: servirebbe la storia delle
|
||||
difficoltà, e il commit-reveal di [ADR-24](ADR-24-l-estrazione-commit-reveal-fra-server.md) toglie a un header fabbricato il
|
||||
potere di scegliere il vincitore).
|
||||
- *Conseguenze:* un header a difficoltà minima costa ancora poco lavoro
|
||||
([LIM-14]); a fermare una catena fabbricata sono la concatenazione, il
|
||||
quorum ([ADR-07](ADR-07-ogni-decisione-sposta-denaro-base-risposta.md)) e il fatto che il seme dipende anche da `server_seed`.
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._apply_header` · `app/rounds/draw.py:header_meets_its_own_target` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py::test_a_multi_block_jump_is_validated_by_fetching_the_headers_in_between`, `tests/unit/test_electrum_listener.py::test_a_fork_is_walked_back_and_followed_instead_of_ending_the_session`, `tests/unit/test_electrum_listener.py::test_a_competing_header_at_the_tip_height_is_a_reorg_to_follow`, `tests/unit/test_draw.py::test_a_header_below_the_pow_limit_is_rejected_even_if_it_meets_its_own_target`
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-27 — Pausa di cortesia fra un round e l'altro
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Pausa di cortesia fra un round e l'altro**
|
||||
(`round_cooldown_seconds`, 30s), contata dalla **conferma del pagamento**, non
|
||||
dall'inizio della chiusura, così i giocatori vedono l'esito. Le regole sono di
|
||||
[RND-04](../ciclo-di-vita-del-round.md#0--apertura) (quando si apre il round
|
||||
successivo) e [RND-28](../ciclo-di-vita-del-round.md#4--pagamento) (da quando
|
||||
si conta).
|
||||
- *Motivazione:* non è nel diagramma originale; senza pausa il round
|
||||
successivo si apriva prima che i client avessero mostrato il risultato.
|
||||
|
||||
*Codice:* `app/rounds/confirmation.py:_on_payout_confirmed` · `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_during_cooldown`
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-28 — La pausa per manutenzione ferma solo il round successivo
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**La pausa per manutenzione ferma solo il round successivo.**
|
||||
È un'azione esplicita dell'operatore con endpoint propri e registrati in
|
||||
audit (`lottery_paused`, `lottery_resumed`), non un campo di configurazione;
|
||||
il round in corso chiude, estrae e paga come qualunque altro round.
|
||||
- *Motivazione:* sospendere a metà un round con denaro nel montepremi
|
||||
creerebbe proprio lo stato bloccato che la pausa vuole evitare.
|
||||
- *Conseguenze:* la stessa pausa è anche il modo in cui il sistema chiede
|
||||
l'intervento dell'operatore, con un motivo (`paused_reason`): una
|
||||
riorganizzazione profonda ([ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-85), un pagamento il cui vincitore
|
||||
non ha più la scommessa in catena (B-82), una transazione abbandonata che
|
||||
conferma in ritardo ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md), B-78), un avvio con `START_PAUSED=true` dopo
|
||||
un ripristino (B-90). Solo l'operatore la toglie. Anche in questi casi il
|
||||
round in corso prosegue, salvo il pagamento trattenuto di B-82.
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:pause_lottery` · `app/api/routes/admin.py:resume_lottery` · `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_paused`, `tests/unit/test_rounds_service.py::test_pause_does_not_interrupt_a_round_in_progress`, `tests/unit/test_admin.py::test_admin_can_pause_and_resume_the_lottery`
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-29 — Senza `fee_address` non si apre nessun round
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-79)
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Senza `fee_address` non si apre nessun round.** Il campo non ha
|
||||
valore predefinito: l'operatore deve impostare il proprio. Finché manca, nessun
|
||||
round si apre (`lottery_configured = false` su `GET /rounds/current`, avviso
|
||||
in `/admin`); un round già in corso invece chiude, estrae e paga anche se
|
||||
l'indirizzo viene cancellato. Qualunque altra condizione che renderebbe un
|
||||
round impagabile va aggiunta nello stesso punto.
|
||||
- *Motivazione:* un round aperto senza `fee_address` raccoglieva scommesse e
|
||||
solo al pagamento scopriva di non poter essere pagato (B-66).
|
||||
- *Conseguenze:* nello stesso punto si aggiunge il controllo che un round
|
||||
con un solo partecipante copra commissione del vincitore, riserva per
|
||||
l'RBF e una commissione sopra la soglia di polvere ([ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79).
|
||||
|
||||
*Codice:* `app/rounds/service.py:rounds_can_open` · `app/rounds/config.py:get_round_config` ·
|
||||
*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address`
|
||||
*Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo
|
||||
`fee_address`.
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-30 — Il montepremi annunciato è quello che sarà pagato
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Round ed estrazione
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il montepremi annunciato è quello che sarà pagato**: il 70%
|
||||
delle sole scommesse confermate, affiancato dai valori «in attesa» che
|
||||
includono le scommesse in volo (valori inclusivi, non differenze).
|
||||
- *Motivazione:* mai annunciare più di quanto l'estrazione e il pagamento
|
||||
considereranno (B-65), senza nascondere al giocatore la propria scommessa
|
||||
appena fatta.
|
||||
- *Conseguenze:* se prima del pagamento una scommessa confermata risulta
|
||||
sparita per una riorganizzazione, il partecipante esce dal calcolo
|
||||
(`reorged_out`) e il pagato scende sotto l'annunciato ([ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82).
|
||||
|
||||
*Codice:* `app/api/routes/rounds.py:current_round` ·
|
||||
*Test:* `tests/unit/test_rounds_route.py::test_advertised_jackpot_covers_only_the_bets_that_will_be_paid`
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-31 — Tutto ciò che spende denaro si scrive prima di inviarlo
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Tutto ciò che spende denaro si scrive prima di inviarlo** (stato
|
||||
`building`, monete già segnate come spese) e si risolve contro la catena
|
||||
dopo; nessuna sessione del database resta aperta durante una chiamata di
|
||||
rete. Vale anche per le sostituzioni RBF: la nuova versione si salva come
|
||||
candidata prima dell'invio (B-77). Solo un **rifiuto esplicito** della rete
|
||||
annulla tutto e risponde **502**; un invio dall'esito incerto (timeout,
|
||||
connessione caduta dopo la scrittura) lascia la riga `building` al
|
||||
riconciliatore e risponde **202** ([ADR-51](ADR-51-dubbio-liberano-mai-fondi.md)).
|
||||
- *Motivazione:* un crash fra invio e salvataggio lasciava monete spese in
|
||||
catena senza traccia (B-08, B-25). Dettagli in
|
||||
[transazioni-e-riconciliazione.md](../transazioni-e-riconciliazione.md).
|
||||
|
||||
*Codice:* `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/tx/broadcast.py:bump_fee` · `app/tx/reconcile.py:reconcile_once` ·
|
||||
*Test:* `tests/unit/test_bets.py::test_bet_is_persisted_before_it_is_broadcast`, `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_scheduler.py::test_trigger_payout_persists_before_broadcasting`, `tests/unit/test_reconcile.py`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# ADR-32 — Il pagamento si ritenta da solo, mai due volte
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il pagamento si ritenta da solo, mai due volte**: un tentativo
|
||||
ogni 60s, usando l'ultimo `payout_failed` in audit come orologio; prima di
|
||||
costruire si rifiuta se esiste già un pagamento `building`/`pending` per il
|
||||
round, e si escludono le monete impegnate da pagamenti non risolti.
|
||||
|
||||
*Codice:* `app/rounds/scheduler.py:RoundScheduler._retry_payout_if_due` · `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
|
||||
*Test:* `tests/unit/test_scheduler.py::test_trigger_payout_skips_when_already_in_flight`, `tests/unit/test_scheduler.py::test_tick_throttles_retry_after_a_recent_payout_failure`, `tests/unit/test_scheduler.py::test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout`
|
||||
@@ -0,0 +1,17 @@
|
||||
# ADR-33 — Scommessa e prelievo si escludono solo tramite il blocco per-utente
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Scommessa e prelievo si escludono solo tramite il blocco
|
||||
per-utente** sulla *costruzione*: un prelievo è accettato anche mentre una
|
||||
scommessa attende conferma, purché le monete confermate e libere lo coprano.
|
||||
- *Motivazione:* il rischio è che scelgano la stessa moneta, già escluso dal
|
||||
blocco e dal fatto che la selezione salta le monete segnate come spese;
|
||||
congelare il resto del saldo non aggiungerebbe sicurezza (B-70).
|
||||
|
||||
*Codice:* `app/tx/locks.py:UserLocks` · `app/api/routes/bets.py:create_bet` · `app/api/routes/withdrawals.py:create_withdrawal` ·
|
||||
*Test:* `tests/unit/test_withdrawals.py::test_withdrawal_is_allowed_while_a_bet_is_still_unconfirmed`
|
||||
@@ -0,0 +1,17 @@
|
||||
# ADR-34 — Saldo spendibile e saldo mostrato sono due numeri distinti
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Saldo spendibile e saldo mostrato sono due numeri distinti.**
|
||||
Si spende solo il confermato (`balance_sats`); si mostra quello che include i
|
||||
resti ancora in volo (`pending_balance_sats`, in ambra finché non conferma).
|
||||
Un prelievo coperto solo dal secondo riceve `balance_pending_confirmation`.
|
||||
- *Motivazione:* la selezione a monete intere sottrae l'intera moneta al
|
||||
saldo confermato già all'invio, molto più di quanto si stia spostando.
|
||||
|
||||
*Codice:* `app/wallet/balance.py:compute_pending_balance` · `app/wallet/balance.py:recompute_balance` · `app/api/routes/users.py:me` ·
|
||||
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_withdrawals.py::test_withdrawal_distinguishes_pending_from_truly_insufficient_balance`
|
||||
@@ -0,0 +1,14 @@
|
||||
# ADR-35 — Aritmetica solo in satoshi interi
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Aritmetica solo in satoshi interi**, anche per i valori letti
|
||||
dalla rete: l'importo di una moneta spesa si ricava dalla transazione grezza,
|
||||
non dal valore decimale restituito dal server.
|
||||
|
||||
*Codice:* `app/tx/broadcast.py:_prevout_amount` · `app/wallet/psbt_builder.py:estimate_vsize` ·
|
||||
*Test:* `tests/unit/test_broadcast.py::test_bump_fee_holds_no_session_open_during_network_calls`
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-36 — Password con Argon2, sessioni con JWT HS256 da 24 ore, revocabili
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Password con Argon2, sessioni con JWT HS256 da 24 ore,
|
||||
revocabili.** Il token porta la versione dell'account (`tv`); cambio password
|
||||
e reset admin la incrementano e invalidano tutte le sessioni precedenti. Un
|
||||
token senza `tv` vale come versione 0, così il rilascio non ha disconnesso
|
||||
nessuno. Argon2 gira fuori dall'event loop.
|
||||
- *Motivazione:* un reset per account compromesso deve buttare fuori subito
|
||||
l'attaccante (B-34); Argon2 inline bloccava l'intero processo, attività in
|
||||
background comprese (B-55).
|
||||
|
||||
*Codice:* `app/auth/security.py:create_access_token` · `app/auth/security.py:decode_access_token` · `app/auth/dependencies.py:get_current_user` · `app/auth/security.py:verify_password_async` ·
|
||||
*Test:* `tests/unit/test_security.py`, `tests/unit/test_users.py::test_change_password_invalidates_the_old_token_but_not_the_new_one`
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-37 — Nessun recupero password, solo reset
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Nessun recupero password, solo reset.** Non si raccoglie
|
||||
nessuna email. L'utente autenticato può *cambiare* la password fornendo
|
||||
quella attuale; chi l'ha persa si rivolge all'operatore, che con
|
||||
`POST /admin/users/{id}/reset-password` ne genera una casuale, mostrata una
|
||||
volta sola e registrata in audit (`admin_password_reset`).
|
||||
|
||||
*Codice:* `app/api/routes/users.py:change_password` · `app/api/routes/admin.py:reset_user_password` ·
|
||||
*Test:* `tests/unit/test_users.py::test_change_password_requires_current_password`, `tests/unit/test_admin.py::test_admin_resets_user_password`
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-38 — Gli username sono un unico spazio di nomi senza distinzione fra maiuscole e minuscole
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Gli username sono un unico spazio di nomi senza distinzione fra
|
||||
maiuscole e minuscole**, garantito da un indice unico su `lower(username)`;
|
||||
il nome resta salvato come digitato (è ciò che mostrano `/admin` e l'audit).
|
||||
Sono ammessi solo caratteri ASCII `[A-Za-z0-9_.-]`, 3–32. La migrazione si
|
||||
rifiuta di partire se due account esistenti differiscono solo per le
|
||||
maiuscole, invece di sceglierne uno da rinominare: entrambi potrebbero avere
|
||||
fondi.
|
||||
- *Motivazione:* «Bob» e «bob» erano due account che condividevano il
|
||||
contatore dei tentativi e un comodo vettore di impersonazione (B-57).
|
||||
|
||||
*Codice:* `app/db/models.py:User` · `app/auth/routes.py:register` · `app/auth/routes.py:login` ·
|
||||
*Test:* `tests/unit/test_auth.py::test_registration_refuses_a_username_differing_only_by_case`, `tests/unit/test_auth.py::test_the_database_itself_rejects_a_case_variant`
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-39 — L'esportazione della chiave privata WIF di un utente è voluta, non una vulnerabilità
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**L'esportazione della chiave privata WIF di un utente è
|
||||
voluta, non una vulnerabilità.** Il server possiede già la master key:
|
||||
l'endpoint espone via API solo ciò che un operatore potrebbe comunque
|
||||
ottenere con uno script (ad esempio per recuperare fondi bloccati). Ogni
|
||||
accesso scrive `admin_privkey_accessed`, e quella registrazione non va tolta.
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:user_privkey` · `app/wallet/hd.py:derive_user_wif` ·
|
||||
*Test:* `tests/unit/test_admin.py::test_admin_exports_user_privkey`
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-40 — L'amministrazione usa un unico token condiviso
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**L'amministrazione usa un unico token condiviso**
|
||||
(`X-Admin-Token` confrontato a tempo costante sui byte UTF-8 con
|
||||
`ADMIN_TOKEN`), non un login per operatore. `/admin` mostra una schermata di
|
||||
inserimento del token, **non è collegato da `/`** (né `/` da `/admin`) ed è in
|
||||
italiano, non tradotto.
|
||||
- *Conseguenze:* l'audit registra *cosa* è cambiato, mai *chi* l'ha fatto
|
||||
([LIM-06]).
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` · `app/main.py:admin_panel` · `app/static/admin.html` ·
|
||||
*Test:* `tests/unit/test_admin.py::test_admin_requires_token`, `tests/unit/test_admin.py::test_admin_rejects_non_ascii_token_with_403_not_500`
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR-41 — Limitazione dei tentativi solo su login e registrazione
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-94)
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Limitazione dei tentativi solo su login e registrazione**, in
|
||||
memoria. Il login usa un ritardo esponenziale per username (dopo 5 errori) e
|
||||
per IP (dopo 20, soglia più alta per non punire una rete condivisa); un
|
||||
login riuscito azzera solo il contatore dello username. La registrazione usa
|
||||
invece una **quota** di 5 account per IP all'ora, consumata solo dagli
|
||||
account creati davvero. I contatori sono limitati in numero.
|
||||
- *Motivazione:* indovinare una password e creare account in massa sono due
|
||||
problemi diversi (B-33, B-58); il ritardo punisce i tentativi, la quota
|
||||
limita la produzione.
|
||||
- *Conseguenze:* nessun limite su scommesse, prelievi, admin ([LIM-07]).
|
||||
Il cambio password (limitato e registrato come `password_changed`) e le
|
||||
segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo
|
||||
delle credenziali ha una lunghezza massima di 256 caratteri
|
||||
([sicurezza.md](../sicurezza.md#limiti-di-frequenza), B-94).
|
||||
|
||||
*Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di
|
||||
bug non hanno limiti né lunghezze massime, e il cambio password non scrive
|
||||
audit.
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-42 — L'IP del client è l'ultimo elemento di `X-Forwarded-For`
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**L'IP del client è l'ultimo elemento di `X-Forwarded-For`**, e
|
||||
Caddy sovrascrive l'header con il vero indirizzo del peer. Un solo proxy
|
||||
fidato sta davanti all'applicazione, che non è esposta direttamente.
|
||||
- *Motivazione:* il primo elemento è scelto dal client; leggerlo rendeva
|
||||
aggirabile ogni controllo per IP (B-54). Basta una delle due misure.
|
||||
|
||||
*Codice:* `app/api/client_ip.py:client_ip` · `Caddyfile` ·
|
||||
*Test:* `tests/unit/test_client_ip.py`, `tests/unit/test_caddyfile_security_headers.py::test_forwarded_for_is_overwritten_with_the_real_peer`
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-43 — Header di sicurezza nel proxy, con `'unsafe-inline'` accettato
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Header di sicurezza nel proxy, con `'unsafe-inline'`
|
||||
accettato**: HSTS, `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy` e una
|
||||
CSP `default-src 'self'` più Google Fonts. Script e stili inline restano
|
||||
permessi perché entrambe le SPA usano `onclick` e `style=""`; rimuoverli è un
|
||||
refactoring a parte. La documentazione automatica dell'API (`/docs`,
|
||||
`/redoc`, `/openapi.json`) è spenta per default (`ENABLE_API_DOCS`).
|
||||
- *Conseguenze:* [LIM-09].
|
||||
|
||||
*Codice:* `Caddyfile` · `app/main.py` · `app/config.py:Settings` ·
|
||||
*Test:* `tests/unit/test_caddyfile_security_headers.py`, `tests/unit/test_docs_disabled.py`
|
||||
@@ -0,0 +1,15 @@
|
||||
# ADR-44 — `/qr/{address}` è pubblico ma limitato
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**`/qr/{address}` è pubblico ma limitato**: nessuna
|
||||
autenticazione (lo usa un `<img>`, che non può portare il token), ma
|
||||
l'indirizzo è validato davvero, la lunghezza è limitata, la resa è in cache
|
||||
(512 voci) e fuori dall'event loop.
|
||||
|
||||
*Codice:* `app/api/routes/qr.py:address_qr` ·
|
||||
*Test:* `tests/unit/test_qr.py`
|
||||
@@ -0,0 +1,16 @@
|
||||
# ADR-45 — Segnalazioni di bug aperte a tutti, triage in admin
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Segnalazioni di bug aperte a tutti, triage in admin.** Si può
|
||||
inviare una segnalazione da autenticati (viene attribuita all'utente) o no;
|
||||
il testo va scritto in inglese, e la pagina lo dice nella lingua del
|
||||
visitatore. Gli stati sono `open` → `read` → `resolved`, e ogni cambio reale
|
||||
di stato è registrato in audit.
|
||||
|
||||
*Codice:* `app/api/routes/bug_reports.py:create_bug_report` · `app/api/routes/admin.py:update_bug_report_status` ·
|
||||
*Test:* `tests/unit/test_bug_reports.py`
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-46 — Il canale SSE è un'aggiunta al polling, non un sostituto
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il canale SSE è un'aggiunta al polling, non un sostituto.**
|
||||
Nessun contenuto e nessuna autenticazione: è solo un «è cambiato qualcosa,
|
||||
ricarica»; la personalizzazione resta negli endpoint REST autenticati. Un
|
||||
unico segnale per tutti (non per utente), una coda da 1 elemento per client
|
||||
così le notifiche ridondanti si fondono, tetto di 500 connessioni globali e 5
|
||||
per IP (oltre, si chiude la connessione più vecchia dello stesso IP). Le
|
||||
pagine ricaricano anche all'evento `open` di ogni riconnessione.
|
||||
- *Alternative scartate:* canale per utente (servirebbe autenticazione sul
|
||||
flusso e sapere a chi interessa ogni evento); Redis pub/sub (solo quando ci
|
||||
saranno più processi).
|
||||
- *Conseguenze:* se il canale cade o è bloccato, le pagine continuano a
|
||||
funzionare con il solo polling.
|
||||
|
||||
*Codice:* `app/rounds/events.py:RoundEventBroadcaster` · `app/api/routes/rounds.py:round_stream` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-47 — L'API parla una sola lingua e risponde con codici
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**L'API parla una sola lingua e risponde con codici.** Ogni
|
||||
errore rivolto all'utente ha `detail = {code, message, params}`: `message` è
|
||||
in inglese per chi non usa la dashboard, `code` è ciò che il frontend traduce
|
||||
(`error.<code>`), `params` porta i valori (gli importi come `*_sats`). Anche
|
||||
l'errore 500 generico ha questa forma (`internal_error`) e non espone il
|
||||
testo dell'eccezione.
|
||||
- *Conseguenze:* aggiungere un errore significa dargli un codice e
|
||||
aggiungere `error.<code>` in tutte le 7 lingue (vedi [api.md](../api.md)).
|
||||
|
||||
*Codice:* `app/api/errors.py:ApiError` · `app/api/errors.py:http_error` · `app/main.py:log_unhandled_exception` ·
|
||||
*Test:* `tests/unit/test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape`
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-48 — Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Le pagine per l'utente (`/` e `/report-bug`) sono in 7 lingue**
|
||||
(en, it, es, fr, de, ru, zh) da un'unica tabella piatta, senza build né
|
||||
download. Lingua scelta da `localStorage.plm_lang`, poi dal browser, poi
|
||||
inglese; nessun'altra lingua di riserva oltre l'inglese. Il selettore di `/`
|
||||
sta nella barra di stato della catena, non nella barra di navigazione
|
||||
(nascosta prima del login). Un elemento è tradotto *o* dagli attributi
|
||||
`data-i18n` *o* dal codice con `t()`, mai da entrambi. Tutte le lingue devono
|
||||
avere esattamente le stesse chiavi.
|
||||
- *Conseguenze:* la parità delle chiavi non è verificata da un test
|
||||
([LIM-21]).
|
||||
|
||||
*Codice:* `app/static/i18n.js:t` · `app/static/i18n.js:applyStaticTranslations` · `app/static/i18n.js:setLanguage` · `app/static/app.js:onLanguageChange`
|
||||
@@ -0,0 +1,12 @@
|
||||
# ADR-49 — Il pannello `/admin` e la pagina `/guida` non sono tradotti
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Il pannello `/admin` e la pagina `/guida` non sono tradotti**
|
||||
(sono in italiano, per l'operatore).
|
||||
|
||||
*Codice:* `app/static/admin.html` · `app/static/guida.html`
|
||||
@@ -0,0 +1,20 @@
|
||||
# ADR-50 — Frontend statici, senza framework né build
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-94)
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Frontend statici, senza framework né build**: due SPA servite
|
||||
direttamente da FastAPI, che parlano con la stessa API JSON (nessuna
|
||||
separazione fra API utente e admin oltre al controllo del token). Le pagine
|
||||
HTML hanno `Cache-Control: no-store`, i file JS e CSS `Cache-Control:
|
||||
no-cache` (il browser li riconvalida a ogni caricamento, così un
|
||||
aggiornamento non lascia una pagina nuova con uno script vecchio;
|
||||
[architettura.md](../architettura.md#allimport), B-94).
|
||||
|
||||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno
|
||||
`Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne
|
||||
hanno.
|
||||
@@ -0,0 +1,39 @@
|
||||
# ADR-51 — Nel dubbio non si liberano mai i fondi
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Denaro in movimento
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Nel dubbio non si liberano mai i fondi** (decisione del
|
||||
2026-09-28). Una riga che sposta denaro (scommessa, prelievo, pagamento) si
|
||||
annulla, liberandone le monete, solo con una **prova**: un rifiuto esplicito
|
||||
della rete all'invio, oppure i suoi ingressi spesi da un'altra transazione
|
||||
confermata (corroborata dal quorum), oppure nessun server del quorum che
|
||||
conosca alcun txid della riga *e* una ritrasmissione dell'ultima versione a
|
||||
tutti i server rifiutata per ingressi mancanti o già spesi. Mai per un
|
||||
timeout, per l'età della riga o per il silenzio di un solo server. Si
|
||||
seguono tutti i txid che una riga ha avuto (ogni versione RBF): la conferma
|
||||
di uno qualsiasi conferma la riga. Le righe `failed` restano sorvegliate per
|
||||
14 giorni; una conferma tardiva scrive `pending_tx_resurrected` e mette in
|
||||
pausa la lotteria ([ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md)). Regole in
|
||||
[transazioni-e-riconciliazione.md](../transazioni-e-riconciliazione.md#riconciliazione)
|
||||
(B-76, B-77, B-78).
|
||||
- *Motivazione:* liberare le monete di una transazione che poi conferma è
|
||||
l'errore peggiore possibile: la scommessa finisce nel montepremi senza
|
||||
partecipante, il prelievo parte mentre la riga dice `failed`, il pagamento
|
||||
viene ricostruito e il vincitore pagato due volte. Tutti e tre accadevano
|
||||
con un invio andato in timeout (B-76), con la conferma di una versione RBF
|
||||
precedente (B-77) o con un riavvio su un server che non aveva ancora visto
|
||||
la transazione (B-78).
|
||||
- *Alternative scartate:* abbandono a tempo (la regola precedente: 120s per
|
||||
`building`, 6 ore per `pending`), che trasforma un ritardo di rete in una
|
||||
perdita; abbandono sul parere del solo server attivo.
|
||||
- *Conseguenze:* una transazione ferma ma viva non si abbandona mai: la si
|
||||
accelera ([ADR-18](ADR-18-l-accelerazione-rbf-paga-chi-riceve.md)); un round può restare in `closing` o `paying_out`
|
||||
finché la rete non decide ([LIM-02], [LIM-03]). Il denaro in dubbio resta
|
||||
congelato e visibile, mai duplicato.
|
||||
|
||||
*Codice:* `app/tx/reconcile.py:reconcile_once` · `app/tx/reconcile.py:_is_due` · `app/bets/service.py:place_bet` · `app/withdrawals/service.py:request_withdrawal` · `app/tx/confirmation.py:poll_once` · `app/tx/broadcast.py:bump_fee` ·
|
||||
*Test:* `tests/unit/test_broadcast_outcome.py`, `tests/unit/test_tx_versions.py`, `tests/unit/test_reconcile_proof.py`
|
||||
@@ -0,0 +1,48 @@
|
||||
# ADR-52 — Le riorganizzazioni della catena si contengono invece di ignorarle
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-82)
|
||||
- **Area:** Conferme e fiducia nella rete
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Le riorganizzazioni della catena si contengono invece di
|
||||
ignorarle** (decisione del 2026-09-28). Tre misure:
|
||||
- *si rilevano*: il listener tiene gli ultimi 10 header e segue una
|
||||
biforcazione fino al punto comune; ogni riorganizzazione scrive
|
||||
`chain_reorg_detected` con profondità e altezze. Una biforcazione che non
|
||||
trova il punto comune entro 10 header è trattata come un header non
|
||||
valido (fine della sessione e rotazione, [ADR-26](ADR-26-validazione-degli-header-concatenata-senza-ricalcolo.md));
|
||||
- *si fermano quelle profonde*: una profondità ≥ 2 mette in pausa la
|
||||
lotteria (`paused_reason = 'reorg'`, [ADR-28](ADR-28-pausa-manutenzione-ferma-round-successivo.md)) finché l'operatore non la
|
||||
riprende;
|
||||
- *si paga solo ciò che è in catena*: prima di costruire un pagamento le
|
||||
monete dell'indirizzo del round si verificano con il quorum; i
|
||||
partecipanti la cui scommessa non c'è più diventano `reorged_out` e la
|
||||
ripartizione 70/30 si calcola su ciò che resta. Se manca la scommessa del
|
||||
vincitore, il pagamento è trattenuto (`payout_blocked`) e la lotteria va in
|
||||
pausa.
|
||||
|
||||
Regole in [connessione-electrum.md](../connessione-electrum.md#4-header-validati-non-creduti)
|
||||
e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
|
||||
(B-82, B-85).
|
||||
- *Motivazione:* la regola precedente («1 conferma, nessun rollback»)
|
||||
presupponeva riorganizzazioni rare e innocue. Con la potenza di PLM in
|
||||
affitto una riorganizzazione è un attacco possibile, e senza queste misure
|
||||
una sola scommessa sparita rende il round impagabile per sempre (B-82),
|
||||
mentre una riorganizzazione di un blocco, normale in qualunque catena,
|
||||
veniva scambiata per un server ostile (B-74).
|
||||
- *Alternative scartate:* ignorare le riorganizzazioni (la regola
|
||||
precedente); un rollback completo (ricalcolare saldi già spesi e ripetere
|
||||
estrazioni già rivelate non è sempre possibile e sposterebbe denaro senza
|
||||
l'operatore); pausa anche per profondità 1 (una riorganizzazione di un
|
||||
blocco è frequente e non tocca i depositi a 6 conferme, [ADR-06](ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)).
|
||||
- *Conseguenze:* un attacco al 51% resta possibile; queste misure lo
|
||||
rendono visibile, fanno sì che non blocchi la lotteria in modo
|
||||
irrecuperabile e limitano il danno a ciò che l'attaccante ha rimosso
|
||||
([LIM-24]).
|
||||
|
||||
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
|
||||
*Test:* `tests/unit/test_electrum_listener.py`
|
||||
*Stato:* da implementare (B-82) · *Oggi:* il pagamento non verifica le monete
|
||||
del round: le prime due misure (rilevare, fermare) sono attive.
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-53 — Il registro di audit è in sola aggiunta e si scrive nella transazione che descrive
|
||||
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
## Decisione
|
||||
|
||||
**Tutto ciò che muove denaro o cambia la configurazione lascia una riga in
|
||||
`audit_log`; la riga nasce da un'unica funzione, è salvata nella stessa
|
||||
transazione del cambiamento che descrive (tranne gli eventi puramente
|
||||
osservativi) e non viene mai modificata né cancellata.**
|
||||
|
||||
- *Motivazione:* l'amministrazione ha un solo token condiviso senza identità
|
||||
per singolo operatore ([ADR-40](ADR-40-l-amministrazione-usa-unico-token-condiviso.md)):
|
||||
il registro è l'unica forma di responsabilità per le azioni dal pannello e il
|
||||
primo posto dove ricostruire cosa è successo dopo un problema.
|
||||
- *Conseguenze:* o si salvano insieme il cambiamento e la sua riga, o nessuno
|
||||
dei due; il registro dice *cosa* è cambiato ma non *chi* (vedi
|
||||
[ADR-40](ADR-40-l-amministrazione-usa-unico-token-condiviso.md)). Il
|
||||
catalogo degli eventi e le regole di scrittura e lettura sono in
|
||||
[audit-log.md](../audit-log.md) (`AUD-01` … `AUD-05`).
|
||||
|
||||
*Codice:* `app/audit/log.py:write_audit_log`
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user