diff --git a/.env.example b/.env.example index 48c8e71..f65bfc3 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,8 @@ ADMIN_TOKEN= # Every business/round parameter (bet amount, round duration/cooldown, min # amount, fee rate, RBF timeout, fee address) is configured live from the # admin panel (/admin) instead of here — see docs/guida-admin.md. + +# Swagger/ReDoc/the raw OpenAPI JSON expose the entire API surface — admin +# endpoints included — to anyone who requests them. Off by default; set to +# true only for local development, never in production. +ENABLE_API_DOCS=false diff --git a/BUGS.md b/BUGS.md deleted file mode 100644 index 2b7a53e..0000000 --- a/BUGS.md +++ /dev/null @@ -1,966 +0,0 @@ -# Known bugs and required fixes - -Full-codebase audit performed on 2026-07-26 against commit `d4e0974` (branch `main`), -covering every Python module under `app/`, both static frontends, and the Docker/Caddy -deployment. The test suite was green at the time of the audit (79 passed), so **none of -the findings below are caught by the existing tests** — every one of them needs a -regression test alongside its fix. - -> **Status: 23 of the 24 findings are fully fixed on `main`** as of 2026-07-27, and the -> 24th ([B-16](#b-16)) is fixed as far as the crash goes — shipping the user guide in the -> image was deliberately deferred. Every entry below keeps its original description — the bug, why it -> mattered, and how it was meant to be fixed — and carries a **Fixed:** note recording -> what was actually done and where the regression test lives. The suite grew from 79 to -> 137 tests. Two entries were fixed differently from the plan (B-15 validates at startup -> rather than in a Pydantic validator, B-09 gained a bounded retry); both say so and why. -> The fixes were then run in the real Docker deployment — see -> [Runtime verification](#runtime-verification-2026-07-27) for what that actually -> confirmed and, more importantly, what it did **not**. - -Findings are ordered by severity. Each entry is self-contained and follows the same shape: -symptom, root cause with `file:line` references, a `*Proposed fix:*` block, and a `*Test:*` -block naming the regression test to add (a few are marked manual where an automated test -would be testing the Docker image layout or the browser, not the code). Items already listed -under "Known gaps / TODO" in `CLAUDE.md` are cross-referenced rather than repeated, except -where this audit found the gap to be worse than documented. - -## Summary - -| ID | Severity | Area | One-line | -|----|----------|------|----------| -| [B-01](#b-01) | Critical | Electrum client/listener | A dropped connection hangs the whole server permanently — no reconnect, no request timeout, no keepalive | -| [B-02](#b-02) | Critical | RBF / bets | A fee bump on a bet tx orphans `RoundParticipant.bet_txid`, wedging the round in `closing` forever | -| [B-03](#b-03) | Critical | Confirmation poller | One unresolvable txid aborts confirmation detection for every other pending tx | -| [B-04](#b-04) | Critical | Balance / UTXO state | No reconciliation path: a dropped tx freezes the spent UTXOs (and the user's funds) forever | -| [B-05](#b-05) | Critical | Admin config / payout | `fee_address` and numeric params are unvalidated; a bad value either wedges the payout or sends the commission to an unspendable script | -| [B-06](#b-06) | High | PSBT builder | Change outputs below the dust limit are created, making the transaction unrelayable | -| [B-07](#b-07) | High | Bets / withdrawals | A broadcast rejection surfaces as HTTP 500, bypassing the structured error contract | -| [B-08](#b-08) | High | Bets | Broadcast happens before the bet is persisted: a commit failure loses the money with no record | -| [B-09](#b-09) | High | Rounds | Race allows two simultaneously-`open` rounds, which permanently blocks all future rounds | -| [B-10](#b-10) | High | Admin / audit | `PUT /admin/config` writes no audit-log entry | -| [B-11](#b-11) | High | Rounds API | The displayed jackpot is not the amount the winner receives | -| [B-12](#b-12) | High | Auth | Registration enforces no password or username validation, unlike change-password | -| [B-13](#b-13) | Medium | Auth | `verify_password` turns a malformed stored hash into a 500 instead of a 401 | -| [B-14](#b-14) | Medium | Admin auth | Admin token compared with `!=` instead of a constant-time comparison | -| [B-15](#b-15) | Medium | Config | Empty `JWT_SECRET` / `XPRV_ENCRYPTION_KEY` are not rejected at startup | -| [B-16](#b-16) | Medium | Deployment | `GET /guida` is broken in Docker: `docs/` is never copied into the image (crash fixed; shipping the guide deferred — see entry) | -| [B-17](#b-17) | Medium | RBF | `_find_change_output` can shrink the recipient output instead of the change | -| [B-18](#b-18) | Medium | Scheduler | DB session held open across Electrum network calls during payout | -| [B-19](#b-19) | Medium | Electrum listener | `tip_height` is assigned without a monotonicity check (reorg) | -| [B-20](#b-20) | Low | RBF | `PendingTransaction.replaced_by_txid` is never written — dead column shown in the admin UI | -| [B-21](#b-21) | Low | Confirmation poller | Dead local `pending_ids`, plus attribute access on detached ORM objects | -| [B-22](#b-22) | Low | Frontend | Amounts rendered by raw division — floating-point artefacts visible to users | -| [B-23](#b-23) | Low | Frontend | Concurrent `withLoading` on the same button can leave it stuck on its loading label | -| [B-24](#b-24) | Low | API contract | The global exception handler answers with a bare-string `detail` | - ---- - -## Runtime verification (2026-07-27) - -The fixed code was built and run via `docker compose up -d --build` against mainnet. This -section separates what the running system **demonstrated** from what is still only covered -by unit tests — the distinction matters, because a green suite is not a working deployment. - -**Caveat on the image that produced this evidence:** it was built while the `Dockerfile` -still carried `COPY docs ./docs`, which was subsequently reverted (see [B-16](#b-16)). The -running container therefore does *not* match the current Dockerfile: `/guida` answers 200 -there, and will answer 404 after the next rebuild. Everything else below is unaffected. - -### Confirmed at runtime - -| Fix | Evidence from the live system | -|---|---| -| [B-15](#b-15) | The app serves traffic, so `validate_runtime_secrets()` passed against the real `.env` — and it is now on the startup path of every deploy. | -| [B-01](#b-01) (partly) | `app.main: Electrum endpoints (in rotation order): santantonio.sytes.net:50002` followed by `app.electrum.listener: Electrum connected to santantonio.sytes.net:50002` — the restructured session setup (`asyncio.wait` over the consumers, `wait_closed()` and the keepalive) connects and stays up against a real server. Only one endpoint is configured, so rotation itself is still only unit-tested. | -| [B-09](#b-09) | `ix_rounds_single_active` exists in the deployed DB and rounds keep opening and closing normally (196 rounds, one per ~98s cycle) — the index and the conflict-retry path do not interfere with normal round creation. | -| [B-04](#b-04) (schema) | The `failure_reason` column is present in the deployed DB; migration `8a1c4e7b2d90` applied cleanly to the live database. | -| [B-04](#b-04) (heuristic) | The one genuinely unknown piece — whether `_tx_exists_on_chain` recognizes a missing tx on *this* server — was tested directly: for a bogus txid the server answers `No such mempool or blockchain transaction`, which the substring check matches, so the function correctly returns `False`. The conservative direction is preserved: any message it does *not* recognize re-raises and leaves the row untouched. | -| [B-16](#b-16) | Confirmed as a bug that had really been firing, not a theoretical one: `logs/app.log` holds two pre-fix `ERROR [app.main] Unhandled error on GET /guida` entries (17:40 and 19:42 on 2026-07-26) ending in `RuntimeError: File at path docs/guida-utente.md does not exist.` | -| No regressions | Zero `ERROR`/`WARNING` lines since the restart on the fixed code. The only errors in the entire log predate it (the two `/guida` failures above, plus two unrelated `asyncio` entries from 2026-07-22). | - -### Still unverified outside the test suite - -Nothing has spent money since the restart: `pending_transactions` holds only rows that were -already `confirmed` beforehand, `withdrawals` is empty, and no `attempt_count` is above 1. -So these fixes have unit coverage and **no runtime evidence at all**: - -- [B-08](#b-08)/[B-07](#b-07) — the two-phase write and the rollback-on-refusal need a real - bet, and a genuinely rejected broadcast, to be seen working. -- [B-02](#b-02)/[B-20](#b-20) — needs an actual RBF bump (`rbf_timeout_seconds` must elapse - with the tx unconfirmed). -- [B-03](#b-03)/[B-04](#b-04) (behaviour) — needs a transaction that really goes missing. - The reconciler has been ticking every 120s with nothing to do, which is silent by design. -- [B-06](#b-06) — needs a UTXO set that produces sub-dust change. -- [B-17](#b-17) — needs a withdrawal attempt; the withdrawal path as a whole has still never - been exercised against a live broadcast, which was already true before this audit. -- [B-22](#b-22)/[B-23](#b-23) — browser-side, verifiable only by using the UI. - -### What remains open (not part of the 24) - -Unchanged by this work, and still the reason this isn't unattended-safe: - -- **Scheduler doesn't resume mid-flight rounds after a restart** — a round left in - `closing`/`drawing`/`paying_out` when the process dies stays stuck. Transaction-level - state now self-heals ([B-04](#b-04)); *round*-level state does not. -- **No automatic payout retry** — a failed payout is audit-logged (`payout_failed`, added by - [B-05](#b-05)) and visible in `/admin`, but acting on it is manual. -- **RBF still handles only the single-change-output case** — the difference is that failure - is no longer permanent: an unbumpable tx is eventually abandoned and its coins released. -- **`/guida` is not served in Docker** by current deliberate decision — the guide is being - reworked; re-adding `COPY docs ./docs` is all it will take. -- **No rate limiting anywhere**, **admin auth is one shared token** with no per-operator - identity, and **single-process assumptions** remain in the SSE broadcaster and the - per-user locks. -- **No automated integration tests against a live Electrum connection** — the verification - in this section was done by hand. - ---- - -## Critical - -### B-01 - -**A dropped Electrum connection hangs the entire server permanently** - -*Files:* `app/electrum/client.py:31-63`, `app/electrum/client.py:85-99`, `app/electrum/listener.py:58-77` - -Three independent defects compose into a single absorbing failure state: - -1. **The read loop's death never propagates.** `connect()` spawns `_read_loop` as a - detached task. When the socket closes, `readline()` returns `b""`, the loop breaks and - the task completes — but `_run_once` is blocked on - `asyncio.gather(self._consume_headers(...), self._consume_scripthash(...))`, and both - consumers are awaiting `asyncio.Queue.get()` on queues nobody will ever fill again. - The `gather` never returns and never raises, so the `except Exception` + - backoff-reconnect logic in `ElectrumListener.run()` is never reached. -2. **`self.client` stays non-`None`.** Every consumer of the client - (`RoundScheduler`, `ConfirmationPoller`, `RbfBumper`, `place_bet`, - `request_withdrawal`, and the `listener.client is None` guards in the bet/withdrawal - routes) therefore keeps treating a dead connection as live. -3. **`request()` has no timeout.** It registers a future in `self._pending`, writes to a - closed writer (`drain()` frequently does not raise on a half-closed socket) and then - `await future` — which nothing will ever resolve, because `_read_loop` is gone. Note - that `_read_loop`'s `finally` only fails the futures that existed at the moment it - died; every future created afterwards hangs forever. - -There is also **no keepalive** (`server.ping`) anywhere. Electrum servers routinely drop -idle connections after ~10 minutes, so on a low-traffic instance this is close to -guaranteed rather than hypothetical. - -*Observable consequences:* deposits stop being credited; the tip stops advancing, so a -round in `drawing` waits in `_wait_for_next_block` forever; `ConfirmationPoller` blocks -on its first `get_transaction` and stops polling entirely; a `POST /bets` request hangs -indefinitely **while holding that user's `UserLocks` entry**, so every later bet or -withdrawal from the same user deadlocks behind it. - -*Reproduction:* start the app, kill the TCP connection out from under it -(`ss -K dst `, or block the port with a firewall rule). No reconnect log -line is emitted, `/rounds/current` keeps reporting the last known `chain_tip_height`, and -a bet request never returns. - -*Proposed fix:* -- Wrap every `request()` in `asyncio.wait_for(...)` with an explicit timeout (10-15s) and - pop the future from `self._pending` on timeout. -- Add a keepalive task issuing `server.ping` every ~60s; a failed ping tears the - connection down. -- Make the read loop's termination observable: either include `self._read_task` in - `_run_once`'s `gather`, or set an `asyncio.Event` in `_read_loop`'s `finally` that - `_run_once` awaits alongside the consumers. On teardown, set `self.client = None` - *before* awaiting the reconnect backoff. - -*Test:* a unit test with a fake stream that closes mid-session must observe -`listener.client is None` and a reconnect attempt; a test that `request()` raises -`ElectrumError`/`TimeoutError` rather than hanging when no response arrives. - -**Fixed.** `app/electrum/client.py` now bounds every `request()` with -`asyncio.wait_for` (`_REQUEST_TIMEOUT_SECONDS = 15`), pops the orphaned future and tears -the connection down on timeout; `_read_loop`'s `finally` sets a `_closed` event, exposed -as `wait_closed()`; and `ping()` was added. `app/electrum/listener.py:_run_once` races the -two notification consumers against `wait_closed()` and a 60s keepalive with -`asyncio.wait(FIRST_COMPLETED)`, so a drop ends the session and `run()` reconnects. -`self.client` is cleared before the reconnect, so callers stop using a dead client. -Beyond the original finding, the listener now **rotates over a list of servers** -(`ELECTRUM_FALLBACK_SERVERS`, `parse_endpoints`): one dead server costs a single attempt -instead of an outage, and the backoff only sleeps once every server has had a turn. -Tests: `tests/unit/test_electrum_client.py` (timeout, `wait_closed`, pending-request -failure, endpoint parsing), `tests/unit/test_electrum_listener.py` (rotation, backoff -reset, no-endpoints case). - -### B-02 - -**An RBF fee bump on a bet permanently wedges the round** - -*Files:* `app/tx/broadcast.py:116-121`, `app/bets/confirmation.py:10-16`, -`app/rounds/scheduler.py:77-86`, `app/rounds/scheduler.py:92-98` - -`bump_fee` rebuilds the transaction, which changes its txid, and updates -`PendingTransaction.current_txid` accordingly. Nothing updates the domain row's copy of -that txid: `RoundParticipant.bet_txid` is written exactly once, at -`app/bets/service.py:80`, and never again (verified by grepping every write to -`bet_txid`). - -When the bumped transaction confirms, `_on_bet_confirmed` looks the participant up by -`RoundParticipant.bet_txid == pending.current_txid`, finds nothing, and returns silently. -Consequences, in order: - -- the participant stays `status == "broadcast"` forever; -- `RoundScheduler._tick` counts participants in `"broadcast"` before allowing the round to - close, so the round stays in `closing` **indefinitely**; -- since a new round cannot open while one is active (`get_active_round`), the entire - lottery stops; -- even if the round were forced closed, `_close_and_draw` only selects - `status == "confirmed"` participants, so that player would have paid the bet without - entering the draw. - -The same staleness affects `Withdrawal.txid`. It is less severe there, because -`_on_withdrawal_confirmed` keys off `pending.withdrawal_id`, so the status does update — -but the txid stored and shown to the user is the replaced one. - -*Reproduction:* place a bet, let `rbf_timeout_seconds` elapse without confirmation so -`RbfBumper` bumps it, then let the bumped tx confirm. `round_participants.status` remains -`broadcast`; the round never leaves `closing`. - -*Proposed fix:* make the bump update the domain row too. Either have `bump_fee` write -through to `RoundParticipant.bet_txid` / `Withdrawal.txid` (it already knows `kind`, -`user_id`, `round_id`, `withdrawal_id`), or — cleaner — stop keying confirmation handlers -on the txid at all: `_on_bet_confirmed` should resolve the participant via -`pending.round_id` + `pending.user_id`, which are immutable. Also set -`replaced_by_txid` (see [B-20](#b-20)) so the chain of replacements is auditable. - -*Test:* bump a pending bet, then run the confirmation handler with the new txid and assert -the participant flips to `confirmed`. - -**Fixed.** Both confirmation handlers now resolve their domain row by immutable -ids — `_on_bet_confirmed` by `(round_id, user_id)`, `_on_payout_confirmed` by `round_id` — -with the old txid lookup kept only as a fallback for pre-existing rows. Independently, -`bump_fee` calls a new `_retarget_txid_references()` that updates -`RoundParticipant.bet_txid`, `Withdrawal.txid`, `Round.payout_txid` **and** -`UtxoEvent.spent_txid` to the new txid, so no stored txid is left pointing at a -transaction that no longer exists. Tests: -`test_confirmation.py::test_bet_confirms_after_an_rbf_bump_changed_the_txid` (plus the -payout equivalent), `test_broadcast.py::test_bump_fee_retargets_every_stored_txid`. - -### B-03 - -**One unresolvable txid stops confirmation detection for everything else** - -*File:* `app/tx/confirmation.py:35-37` - -```python -for pending_id, txid, kind in [...]: - tx = await client.get_transaction(txid, verbose=True) # not guarded -``` - -If a txid is no longer knowable by the server — dropped from the mempool, replaced by an -RBF bump whose old entry is still `pending`, or simply an Electrum server that answers -with an error — `ElectrumError` propagates out of `poll_once` and **aborts the loop before -the remaining pending transactions are checked**. `ConfirmationPoller.run` logs it and -retries every 10s, failing at the same row every time. - -Because the poller is the single mechanism that confirms bets, payouts and withdrawals, -one stuck row means: no bet ever confirms again (so no round ever closes — see -[B-02](#b-02)), no payout ever completes, no withdrawal ever settles. Recovery requires -manual DB surgery. - -*Proposed fix:* wrap the per-transaction lookup in `try/except Exception`, log at -`warning` with the txid, and `continue`. Optionally track consecutive failures per row so -a permanently-unknown tx can be escalated to an operator (which is also the hook -[B-04](#b-04) needs). - -*Test:* `poll_once` with two pending rows where the first raises must still confirm the -second and return `1`. - -**Fixed.** The per-transaction lookup in `poll_once` is wrapped in -`try/except Exception`: it logs the txid at warning level and continues, leaving the -judgement about a permanently-unknown tx to the reconciler ([B-04](#b-04)). Test: -`test_confirmation.py::test_one_unresolvable_txid_does_not_block_the_others`, which -asserts the healthy row still confirms and the unknown one is left `pending` rather than -abandoned here. - -### B-04 - -**No reconciliation: a dropped transaction freezes the spent UTXOs forever** - -*Files:* `app/bets/service.py:69-73`, `app/withdrawals/service.py:65-68`, -`app/wallet/balance.py:9-18` - -`spent_txid` is set optimistically at broadcast time and is **never cleared anywhere in -the codebase**. `recompute_balance` only sums UTXOs with `spent_txid IS NULL`, so those -coins are permanently excluded from the user's balance. - -If the transaction never confirms and eventually disappears from the mempool — which -`CLAUDE.md` already acknowledges is a realistic outcome, since the RBF path raises -`RbfError` and gives up whenever there is no change output large enough to absorb the -bump — the UTXOs remain perfectly spendable on-chain while the database considers them -gone. The user's funds are silently lost from their point of view. - -More broadly, nothing in the system ever writes a terminal failure state: there is no -`status = "failed"` on `PendingTransaction`, `Withdrawal` or `RoundParticipant`, and no -startup routine that re-scans in-flight transactions against the chain. Combined with -`restart: unless-stopped` and the already-documented scheduler-resume gap, the system has -several states it can only be pulled out of by hand. - -*Proposed fix:* add a reconciliation task (and run it once at startup) that, for every -`PendingTransaction` in `pending` older than some threshold, asks the chain whether the -tx exists. If it is gone: -- mark the row `failed`, and set `replaced_by_txid`/a failure reason; -- clear `spent_txid` on the UTXOs it consumed (they are identifiable by parsing - `raw_tx_hex`'s inputs); -- call `recompute_balance`; -- roll the domain row back (delete the `RoundParticipant`, mark the `Withdrawal` - `failed`) and audit-log the event. - -*Test:* given a pending bet whose tx is unknown to the chain, the reconciler must restore -the user's balance and remove the participant. - -**Fixed** by a new component, `app/tx/reconcile.py` -(`PendingTransactionReconciler`, started from the lifespan and running every 120s, -including once at startup). Per non-terminal `PendingTransaction` it asks the chain -whether the tx exists: a `building` row whose tx is there gets promoted, a row whose tx is -gone is marked `failed` with a `failure_reason` (new column), its inputs released -(`spent_txid` cleared, only where it still matches this row's txid), the balance -recomputed, and the domain row rolled back — participant deleted, withdrawal marked -`failed`, payout txid cleared. Grace periods differ by state (120s for `building`, 6h for -`pending`) and a transport failure never abandons anything. Tests: -`tests/unit/test_reconcile.py` (6 cases, including the "broken connection must not release -coins" one). - -### B-05 - -**`fee_address` and the numeric config params are unvalidated** - -*File:* `app/api/routes/admin.py:70-80`, consumed at `app/rounds/scheduler.py:162-198` - -`PUT /admin/config` assigns whatever it is given straight onto the `RoundConfig` row. Two -distinct failure modes: - -1. **Malformed `fee_address`** → `build_payout_transaction` calls - `script.Script.from_address(fee_address)`, which raises `EmbitError`. `_trigger_payout` - only catches `InsufficientFundsError`, so the exception escapes through - `_close_and_draw` up to the `except Exception` in `RoundScheduler.run()`. The round is - left in `paying_out` with no retry (the payout-retry gap in `CLAUDE.md`), i.e. wedged. -2. **Well-formed but foreign `fee_address`** (e.g. a Bitcoin `bc1...`) → this is *worse*, - because it parses fine into a valid witness program. The payout is built, signed and - broadcast, and the 30 % commission of every round lands on a script nobody holds the - key for. This is exactly the failure mode `app/wallet/address.py` was written to - prevent for user withdrawals; the check was never applied to the operator-supplied - address. - -Numeric fields are equally unguarded: `round_duration_seconds = 0` (round expires the -instant it opens), `fee_rate_sat_vb = 0` (fee-less transactions are never relayed, so -every bet/payout hangs and the whole pipeline stalls), negative `bet_amount_sats`, -`draw_animation_seconds` far larger than the round itself. - -*Proposed fix:* validate in `RoundConfigUpdate` (Pydantic `field_validator` / -`Field(gt=0)`): -- `fee_address` must pass `is_valid_plm_address`; -- `bet_amount_sats`, `round_duration_seconds`, `fee_rate_sat_vb`, `rbf_timeout_seconds` - strictly positive, with sane upper bounds; -- `round_cooldown_seconds`, `draw_animation_seconds` non-negative. - -Independently, `_trigger_payout` should catch `Exception` around the build/broadcast, -audit-log the failure, and leave the round in a state a retry routine can pick up. - -*Test:* `PUT /admin/config` with a `bc1...` fee address and with `fee_rate_sat_vb = 0` -must both return 422/400 and leave the stored config untouched. - -**Fixed.** `RoundConfigUpdate` now validates: `fee_address` must pass -`is_valid_plm_address`, and every numeric field carries bounds (`fee_rate_sat_vb >= 1`, -`round_duration_seconds >= 30`, etc.). Separately `_trigger_payout` catches `Exception` -around the build/broadcast, logs it and writes a `payout_failed` audit entry so a stuck -round is visible in `/admin` rather than only in the log file. Automatic payout retry -remains an open pre-existing gap (documented in CLAUDE.md), unchanged by this fix. Test: -`test_admin.py::test_config_rejects_unusable_values`, parameterized over the `bc1…` -address, a broken checksum, `fee_rate_sat_vb=0`, `round_duration_seconds=0` and more. - ---- - -## High - -### B-06 - -**Dust change outputs are created** - -*File:* `app/wallet/psbt_builder.py:98-99` and `:166-167` - -Both builders use `if change > 0:` as the sole condition for adding the change output. Any -leftover below the P2WPKH dust threshold (~294 sat at the standard 3000 sat/kvB dust -relay fee) produces a transaction that relaying nodes reject outright as `dust`. The -resulting `ElectrumError` is unhandled (see [B-07](#b-07)), so the user gets an opaque -HTTP 500 and cannot bet or withdraw at all until their UTXO set happens to change. - -*Proposed fix:* introduce a `DUST_LIMIT_SATS` constant; add the change output only when -`change >= DUST_LIMIT_SATS`, otherwise leave the remainder to the fee (and reflect that in -the returned `fee_sats`/`change_sats` so the accounting stays honest). Note the fee -estimate at `psbt_builder.py:86` already assumes two outputs unconditionally, so dropping -change does not underpay. - -*Test:* build a transaction whose change lands at 100 sat and assert a single-output tx -with the remainder folded into the fee. - -**Fixed.** `DUST_LIMIT_SATS = 294` added to `app/wallet/psbt_builder.py`; both -builders fold sub-dust change into the fee instead of creating the output, and refuse a -sub-dust recipient/winner/commission amount with a dedicated error code. Tests in -`test_psbt_builder.py`: dust change folded into the fee (asserting nothing vanishes — -inputs still equal outputs plus fee), change exactly at the limit still paid back, and a -dust-sized recipient refused. - -### B-07 - -**Broadcast failures surface as HTTP 500** - -*Files:* `app/bets/service.py:67`, `app/withdrawals/service.py:63` - -`await client.broadcast(built.raw_hex)` is not guarded. Every node-side rejection — -fee below the relay minimum, dust output ([B-06](#b-06)), mempool conflict, non-standard -tx — raises `ElectrumError`, which the global handler in `app/main.py:74-77` turns into -`{"detail": "internal server error"}`. That bypasses the whole error contract documented -in `app/api/errors.py`: no `code`, nothing translatable, no actionable message. - -*Proposed fix:* wrap the broadcast, and raise -`BetError("broadcast_failed", ...)` / `WithdrawalError("broadcast_failed", ...)` carrying -the node's message in `params`. Add `error.broadcast_failed` to all 7 languages in -`app/static/i18n.js` (per the i18n contract in `CLAUDE.md`). Answer 502/503 rather than -400, since the failure is not the client's fault. - -*Test:* `place_bet` / `request_withdrawal` with a client stub whose `broadcast` raises must -raise `BetError`/`WithdrawalError` with code `broadcast_failed` — and must leave no -`spent_txid`, no participant and no pending row behind. - -**Fixed.** Both services wrap the broadcast and raise -`BetError`/`WithdrawalError("broadcast_failed", …)`; the routes answer **502** rather than -400, since the network refused it, not the caller. `error.broadcast_failed` was added to -all 7 languages in `i18n.js` (along with `amount_below_dust_limit`, -`withdrawal_to_own_address`, `internal_error`, `guide_unavailable`) — key parity verified, -123 keys per language. Tests: -`test_bets.py::test_failed_broadcast_reports_the_broadcast_failed_code` and the withdrawal -equivalent. - -### B-08 - -**Bets are broadcast before they are persisted** - -*File:* `app/bets/service.py:67-94` - -The order today is: broadcast → mutate `spent_txid` → insert `RoundParticipant` + -`PendingTransaction` → `commit()`. If the commit fails, or the process dies in that -window, the transaction is already irreversibly on the chain: the money has moved to the -pool address, but **no record of the bet exists** — no participant (so no entry in the -draw), no pending row (so no RBF, no confirmation tracking), and the UTXOs are not even -marked spent, so the next bet attempt will try to double-spend them. - -The same shape exists in `request_withdrawal`, where the `Withdrawal` row is flushed -before broadcast but only committed after. - -*Proposed fix:* persist the intent first. Insert the `PendingTransaction` (and the -`RoundParticipant`) in a `building`/`unbroadcast` state, commit, then broadcast, then flip -to `broadcast` in a second commit. A crash between the two leaves a row the -reconciliation task from [B-04](#b-04) can resolve against the chain in either direction. - -*Test:* with a client stub that broadcasts successfully but a session whose second commit -raises, the `PendingTransaction` must still exist afterwards (in its pre-broadcast state) -rather than the bet vanishing entirely. - -**Fixed.** Both `place_bet` and `request_withdrawal` are now two-phase: write -the rows in a `building` state and commit, *then* broadcast, then promote to -`broadcast`/`pending` in a second commit. A failed broadcast runs a release helper that -frees the reserved UTXOs, restores the balance, removes the participant (or marks the -withdrawal `failed`) and audit-logs it. A crash between the phases leaves a `building` row -for the reconciler ([B-04](#b-04)) to resolve either way. Tests: -`test_bets.py::test_bet_is_persisted_before_it_is_broadcast` — which probes committed state -from an independent session *during* the broadcast, and which caught a real mistake in the -first draft of this fix (the `_pending_transaction` helper still hardcoded -`status="pending"`, so rows were born already-broadcast and would have got the reconciler's -6-hour grace instead of 120s) — plus the two rollback tests. - -### B-09 - -**Two rounds can be opened concurrently, blocking every future round** - -*File:* `app/rounds/service.py:44-60` - -`open_new_round_if_needed` performs a read (`get_active_round`) followed by an insert with -no lock and no database-level uniqueness guarantee, and it is called both from -`RoundScheduler._tick` and from **every** `place_bet`. Two concurrent callers can both -observe "no active round" and both insert a row with `status="open"`. - -`get_active_round` then quietly hides the problem — `select(...).order_by(Round.id.desc())` -+ `scalar()` returns only the newest — while the older round stays `open` forever, never -ticked, never closed. Since `get_active_round` matches on status, no further round can -ever open once the newer one closes: the lottery stops for good. - -*Proposed fix:* the simplest robust option is to make the `RoundScheduler` the only writer -and have `place_bet` merely read the active round (rejecting the bet if there is none — -the scheduler opens one within 5s anyway). Additionally add a DB guard: a partial unique -index over `status IN ('open','closing','drawing','paying_out')`, or an advisory/serialized -transaction around the read-then-insert. `get_active_round` should also log loudly if it -ever sees more than one row. - -*Test:* two concurrent `open_new_round_if_needed` calls on separate sessions must yield -exactly one round. - -**Fixed** at the database level: `ix_rounds_single_active`, a unique index over -the constant expression `(1)` restricted to the active statuses (model + -migration `8a1c4e7b2d90`, which first closes any pre-existing duplicates, keeping the -newest — verified against a DB seeded with two active rounds). `open_new_round_if_needed` -catches the resulting `IntegrityError`, rolls back and returns the winner's round. -**Deviation from the plan:** rather than making the scheduler the only writer (which would -have meant the first bet after a cooldown couldn't open a round), it keeps both callers and -adds a bounded retry — a conflict where nothing is active yet means the winner simply -hadn't committed, and a bet must not fail on that timing. `get_active_round` also logs -loudly if it ever sees more than one active round. Tests in `test_rounds_service.py`: the -race-recovery path (forced deterministically by making the first look miss the existing -round — real concurrency on a shared in-memory SQLite connection isn't isolated enough to -test this honestly), the DB refusing a second active round, and closed rounds coexisting -with an active one. - -### B-10 - -**`PUT /admin/config` writes no audit-log entry** - -*File:* `app/api/routes/admin.py:70-80` - -`/admin/pause` and `/admin/resume` both call `write_audit_log`; the config update — which -can change `fee_address` (where 30 % of every pool goes), `bet_amount_sats` and the fee -rate — writes nothing. This contradicts `CLAUDE.md`, which states that `audit_log` -records *what* changed (only the *who* is documented as missing). - -Secondary issue: `paused` is part of `_CONFIG_FIELDS`, so it can be toggled through -`PUT /config`, silently bypassing the deliberately audit-logged `pause`/`resume` -endpoints. - -*Proposed fix:* write a `config_updated` audit entry containing the changed fields as -`{field: {"from": old, "to": new}}` — computed before assignment. Remove `paused` from -`_CONFIG_FIELDS` (keep it in the response model) so the pause switch has exactly one -audited path. - -*Test:* `PUT /admin/config` changing `fee_address` must produce exactly one -`config_updated` audit row carrying both the old and the new value; `paused` must be -rejected (or ignored) on that endpoint. - -**Fixed.** `PUT /admin/config` computes a before/after diff and writes a -`config_updated` audit entry (`{field: {"from": …, "to": …}}`), skipping no-op updates. -`paused` was removed from `_CONFIG_FIELDS` so the maintenance switch has exactly one -audited path (`/admin/pause`, `/admin/resume`); it remains in the response model. Tests: -`test_admin.py::test_config_update_is_audit_logged`, the no-op case, and -`test_pause_cannot_be_toggled_through_the_config_endpoint`. - -### B-11 - -**The displayed jackpot is not what the winner receives** - -*File:* `app/api/routes/rounds.py:118-144` - -```python -pool_amount_sats = participant_count * config.bet_amount_sats -jackpot_sats = pool_amount_sats * 70 // 100 -``` - -Three independent divergences from the amount actually paid out: - -1. The real pool is `sum(p.bet_amount_sats)`, and each participant's `bet_amount_sats` is - `recipient_sats` — the bet amount **minus that bet's network fee** - (`psbt_builder.build_signed_transaction`). -2. The payout deducts its own network fee from the winner's 70 % share - (`build_payout_transaction`), so the winner receives strictly less than 70 % of the - pool. -3. `participant_count` counts every `RoundParticipant` row, including bets still in - `broadcast` that may never confirm — and those are excluded from the draw and the pool - by `_close_and_draw`. - -Plus: it multiplies by the *current* `bet_amount_sats`, which an operator can change -mid-round, retroactively changing the advertised jackpot of a round already in progress. - -The in-code comment explicitly promises "what's displayed should match what the winner -actually receives", which is the opposite of the behaviour. - -*Proposed fix:* compute the pool from the participants' stored `bet_amount_sats` -(optionally restricted to `confirmed`), derive the 70 % share from that, and either -subtract an estimated payout fee or relabel the field as an estimate in the UI. Keep -`participant_count` as-is for display but consider exposing `confirmed_count` separately. - -*Test:* with two confirmed participants whose stored `bet_amount_sats` is below the -configured `bet_amount_sats` (fee already deducted), `GET /rounds/current` must report a -`jackpot_sats` derived from the stored amounts, and must not change when -`RoundConfig.bet_amount_sats` is edited mid-round. - -**Fixed.** `GET /rounds/current` now sums the participants' stored -`bet_amount_sats` (each already net of its own bet's fee) instead of multiplying -`participant_count` by the current configured amount. The remaining, unavoidable -imprecision — the payout tx's own fee, deducted from the winner's share and unknowable -until the payout is built — is documented in the code rather than silently promised away. -Test: `test_rounds_route.py::test_jackpot_comes_from_the_participants_actual_bets`, which -also asserts that editing `bet_amount_sats` mid-round no longer moves a running round's -jackpot. - -### B-12 - -**Registration has no password or username validation** - -*File:* `app/auth/routes.py:18-56` - -`POST /users/me/change-password` enforces `_MIN_PASSWORD_LENGTH = 8` -(`app/api/routes/users.py:15`), and the frontend re-checks it. `POST /auth/register` -enforces nothing: an empty username and a one-character password are both accepted, and -there is no `minlength` on the registration inputs in `app/static/index.html` either. On a -custodial system holding real funds, this is the wrong default. - -Secondary defect in the same handler: the `_MAX_REGISTER_RETRIES` loop catches *any* -`IntegrityError`. A username collision that slips past the pre-check (concurrent -registration) is therefore retried five times and finally reported as -`derivation_index_conflict`, which is misleading for both the user and the operator. - -*Proposed fix:* validate `username` (length, allowed character set) and `password` -(shared minimum-length constant, reused by `users.py`) in `RegisterRequest`; mirror the -constraints in the HTML form. Inspect the `IntegrityError` and re-raise `username_taken` -when it is the username constraint that failed. - -*Test:* `POST /auth/register` with an empty username and with a 3-character password must -both be rejected (422/400) and create no user; a registration racing an existing username -must answer `username_taken`, not `derivation_index_conflict`. - -**Fixed.** `MIN_PASSWORD_LENGTH` moved to `app/auth/security.py` and is now -shared by registration and the password change, so the two can't drift. `RegisterRequest` -constrains username (3–32 chars, `[A-Za-z0-9_.-]`) and password; the HTML form mirrors it -with `minlength`/`pattern`/`required`, and `register()` pre-checks the length so the -failure is immediate and translated. The `IntegrityError` handler now distinguishes a -username collision (answers `username_taken`) from a derivation-index collision (retries). -Tests: `test_users.py::test_register_rejects_weak_credentials` (parameterized) and the -positive case. - ---- - -## Medium - -### B-13 - -**A malformed stored hash becomes a 500 instead of a 401** - -*File:* `app/auth/security.py:16-20` - -`verify_password` catches only `VerifyMismatchError`. Argon2 raises `InvalidHashError` for -a hash it cannot parse and `VerificationError` for other verification failures, both of -which escape as an unhandled 500. - -*Proposed fix:* catch `argon2.exceptions.VerificationError` (the superclass of -`VerifyMismatchError`) plus `InvalidHashError` and return `False`, so an unusable stored -hash reads as "wrong password" rather than as a server fault. - -*Test:* `verify_password("x", "not-a-hash")` returns `False`; `POST /auth/login` against a -user row with a corrupted `password_hash` answers 401. - -**Fixed.** `verify_password` catches `VerificationError` (the superclass of -`VerifyMismatchError`) and `InvalidHashError` separately, returning `False` in both cases -and logging the unparseable-hash case as an error, since that one is a data problem worth -noticing. Tests: `test_security.py::test_verify_password_returns_false_for_an_unparseable_hash`, -plus one confirming a genuinely wrong password is still rejected. - -### B-14 - -**Admin token compared non-constant-time** - -*File:* `app/api/routes/admin.py:20-22` - -`x_admin_token != settings.admin_token` is a short-circuiting comparison. This token gates -the private-key export endpoint, so it deserves a constant-time comparison. - -*Proposed fix:* `secrets.compare_digest(x_admin_token, settings.admin_token)`, keeping the -existing "empty configured token means always deny" short-circuit *before* it (compare_digest -on two empty strings returns `True`). - -*Test:* the existing admin auth tests still pass, plus one asserting an empty -`ADMIN_TOKEN` setting denies an empty `X-Admin-Token` header. - -**Fixed.** `require_admin` uses `secrets.compare_digest`, with the -empty-configured-token check kept *ahead* of it — `compare_digest("", "")` returns `True`, -so the original order would have opened the admin panel on any instance without an -`ADMIN_TOKEN`. Covered by the existing admin auth tests. - -### B-15 - -**Empty secrets are not rejected at startup** - -*File:* `app/config.py:13-18` - -`jwt_secret` and `xprv_encryption_key` both default to `""`. With an empty `JWT_SECRET`, -PyJWT raises `InvalidKeyError: HMAC key must not be empty` on every login and -registration — a 500 with no hint about the real cause (verified locally). With an empty -`XPRV_ENCRYPTION_KEY`, Fernet fails on the first key derivation instead. Either way the -container starts up healthy and only fails once a user touches the broken path. - -*Proposed fix:* a Pydantic `field_validator` (or `model_validator`) on `Settings` rejecting -empty `jwt_secret`/`xprv_encryption_key`, with a minimum length on `jwt_secret` (32 bytes, -per the `InsecureKeyLengthWarning` PyJWT already emits in the test suite). The process must -refuse to boot instead of half-working. Note the tests currently rely on a short secret, so -they need updating alongside. - -*Test:* constructing `Settings(jwt_secret="")` raises `ValidationError`. - -**Fixed**, but **not as planned.** A Pydantic `field_validator` on `Settings` -would have run at import time in every module that reads config — including the whole test -suite, which has no `.env` — so a fresh clone would have failed at collection. Instead -`validate_runtime_secrets()` (in `app/config.py`) is called from the app's lifespan: the -server still refuses to serve half-configured, without coupling imports to a gitignored -file. It reports all problems at once and treats an empty `ADMIN_TOKEN` as non-fatal -(`require_admin` already denies everything, so the effect is a locked panel, not an open -one). Tests: `tests/unit/test_config.py` (6 cases, using `_env_file=None` so a developer's -real `.env` can't influence the result). - -### B-16 - -**`GET /guida` is broken in Docker** - -*Files:* `Dockerfile:38-42`, `app/main.py:98-103` - -The image copies `pyproject.toml`, `app/`, `migrations/`, `alembic.ini` and `scripts/`. -`docs/` is never copied (it is not in `.dockerignore` — it is simply not `COPY`-ed), so -`FileResponse("docs/guida-utente.md")` raises inside the container and the help link in -the navbar returns a 500 in every real deployment. - -*Proposed fix:* add `COPY docs ./docs` to the `Dockerfile` (treating the guide as a shipped -asset), or move the guide under `app/static/` and serve it from there. Whichever is chosen, -`GET /guida` should degrade to a 404 with a clear message rather than an unhandled -exception when the file is absent. - -*Test:* not unit-testable as-is (it depends on the image layout) — verify with -`docker compose up -d --build && curl -k https://localhost/guida`, and add that check to -`docs/running-the-server.md`'s smoke list. - -**Partially fixed, by explicit decision.** The crash is gone: `GET /guida` checks -the file exists and answers a structured 404 (`guide_unavailable`, translated in all 7 -languages) with an error logged, instead of raising and returning a 500. - -Shipping `docs/` in the image (`COPY docs ./docs`) was written and then **reverted at the -owner's request** — the user guide is going to be reworked first, so there is no point -baking the current one into the image. Net effect today: in Docker, `/guida` answers 404 -rather than serving the guide, and the navbar link leads nowhere useful. That is a known, -accepted state, not an oversight; adding the `COPY` line is all it takes once the guide is -ready. Verification once it is: `docker compose up -d --build && curl -k https://localhost/guida`. - -### B-17 - -**`_find_change_output` can shrink the recipient output** - -*File:* `app/tx/broadcast.py:59-63` - -It returns the *first* output whose address matches the sender's own address. Nothing -prevents a withdrawal to the user's own address (`request_withdrawal` never compares -`external_address` with `user.address`), in which case output 0 is the recipient and -output 1 is the change — and a fee bump would reduce the recipient output. The same case -makes `compute_pending_balance` (`app/wallet/balance.py:55-59`) count both outputs, so the -displayed pending balance double-counts the withdrawn amount. - -*Proposed fix:* reject a withdrawal whose destination is the user's own address (a -distinct error code — it is a user mistake, not a system limit), and/or identify the -change output by index rather than by address, recording it on `PendingTransaction` at -build time. - -*Test:* `request_withdrawal` to `user.address` is rejected; and given a hand-built tx with -two outputs to the same address, the bump reduces the *change* one (the last), leaving the -recipient amount untouched. - -**Fixed.** `request_withdrawal` rejects a destination equal to the user's own -deposit address with its own error code — it was a no-op that cost a fee, and it was the -only way for the recipient and change outputs to be indistinguishable by address (which -would have made a fee bump shrink the recipient output, and made -`compute_pending_balance` count the amount twice). Test: -`test_withdrawals.py::test_withdrawal_to_own_address_is_rejected`, which also asserts no -UTXO was touched. - -### B-18 - -**DB session held open across Electrum network calls during payout** - -*File:* `app/rounds/scheduler.py:162-220` - -`_trigger_payout` opens a session and, inside it, awaits `client.listunspent(...)` and -`client.broadcast(...)` before committing. On SQLite that holds the write lock for the -duration of two network round-trips (unbounded, per [B-01](#b-01)); on Postgres it becomes -a long-running transaction. - -*Proposed fix:* restructure into three phases — read config/winner and close the session → -do the network work (`listunspent`, build, `broadcast`) → reopen a session to persist the -result and the `PendingTransaction`. This also makes the [B-05](#b-05) error handling -easier to place, since the failure-prone part is no longer inside a transaction. - -*Test:* the existing payout tests must still pass; add one asserting no session is open -while the stub client's `broadcast` is being awaited (e.g. by having the stub attempt a -write through a second session). - -**Fixed.** `_trigger_payout` is now explicitly three phases — read (session -closed), build+broadcast, persist — so no session is held across a network call. The -restructuring is also what made [B-05](#b-05)'s error handling easy to place, since the -failure-prone part is no longer inside a transaction. Covered by the existing scheduler -tests. - -### B-19 - -**`tip_height` is assigned without a monotonicity check** - -*File:* `app/electrum/listener.py:92-101` - -`self.tip_height = header["height"]` accepts a lower height on a reorg. Since -`_wait_for_next_block` compares `self._listener.tip_height <= tip_at_close`, a regression -silently extends the wait by a block. - -*Proposed fix:* full reorg handling is out of scope for v1 by explicit design decision, but -the assignment should be guarded: keep `max(self.tip_height, header["height"])` and log a -warning when a header arrives with a lower height, so the condition is at least visible in -the logs when it happens. Do not update `tip_header_hex` from a header that loses this -comparison, or height and hash would describe different blocks. - -*Test:* feeding the consumer a header at height N then N-1 leaves `tip_height == N` and the -hash unchanged. - -**Fixed.** Header handling moved into `_apply_header()`, which refuses a height -below the current tip, logs a warning when that happens, and applies height and hex -together — a losing header's hex must not be stored, since that hex is the draw's entropy -source and a mismatched pair would be worse than a stale one. Test: -`test_electrum_listener.py::test_tip_never_moves_backwards`. - -## Low / hygiene - -### B-20 - -**`replaced_by_txid` is never written** - -*Files:* `app/db/models.py:119`, `app/tx/broadcast.py:116-120`, -`app/api/routes/admin.py:261` - -`bump_fee` overwrites `current_txid` in place and never records what the old txid was. The -column exists, is selected, and is rendered in the admin "Transazioni pendenti" table, -where it therefore always shows `—`. - -*Proposed fix:* set it in `bump_fee` (naturally part of the [B-02](#b-02) fix) so the -replacement chain is auditable. Note the column semantics are the reverse of what the name -suggests for an in-place update — it will hold the *previous* txid, so either rename it -(`previous_txid`, via a migration) or document the direction on the model. - -*Test:* after a bump, the row's `replaced_by_txid` holds the pre-bump txid and -`current_txid` the new one. - -**Fixed.** `bump_fee` records the pre-bump txid in `replaced_by_txid`. The -model now documents that the column points *backwards* despite its name (renaming it would -need a migration and would churn the admin UI; the direction is stated on the field -instead). Test: covered by -`test_broadcast.py::test_bump_fee_retargets_every_stored_txid`. - -### B-21 - -**Dead code and detached-object access in the confirmation poller** - -*File:* `app/tx/confirmation.py:29-35` - -`pending_ids` (line 32) is computed and never used. Line 35 rebuilds the same tuples from -ORM objects whose session has already been closed; it works today only because -`expire_on_commit=False` and no commit intervened, so flipping that engine setting would -break it silently. - -*Proposed fix:* drop `pending_ids` and select the plain columns -(`select(PendingTransaction.id, PendingTransaction.current_txid, PendingTransaction.kind)`) -instead of hydrating entities, so nothing outlives the session. - -*Test:* covered by the existing `tests/unit/test_confirmation.py` — it must stay green with -`expire_on_commit=True` forced on the test session factory. - -**Fixed.** `poll_once` selects plain columns -(`select(PendingTransaction.id, .current_txid, .kind)`) instead of hydrating entities, so -nothing outlives the session, and the unused `pending_ids` local is gone. Covered by the -existing (and new) `test_confirmation.py` cases. - -### B-22 - -**Amounts rendered by raw division** - -*Files:* `app/static/app.js` (multiple: `:295`, `:424`, `:660`, `:673`, `:680`), -`app/static/admin.js` (table cells) - -Every amount is displayed as `sats / SATS_PER_PLM` with no formatting, so values like -`0.7000000000000001` are reachable in the balance, the jackpot and the admin tables. - -*Proposed fix:* one `formatPlm(sats)` helper (fixed decimals, locale-aware grouping via -`Intl.NumberFormat` with the language already resolved by `i18n.js`), used by every display -site. Amounts sent *to* the server must keep going through `Math.round(x * SATS_PER_PLM)` — -the formatter is for display only. - -*Test:* manual — with a 0.7 PLM jackpot and a 12345678.9 PLM balance, no artefacts and no -locale mismatch against the selected language. - -**Fixed.** `formatPlm(sats)` added to `app.js` (and `fmtPlm` to `admin.js`), -using `Intl.NumberFormat` with the already-resolved language; every display site routes -through it. Input fields deliberately keep the raw value — a grouped, localized string -would break `parseFloat` — and amounts sent to the server still go through -`Math.round(x * SATS_PER_PLM)`. Verification is manual (browser rendering). - -### B-23 - -**Concurrent `withLoading` on the same button** - -*File:* `app/static/app.js:20-31` - -`withLoading` snapshots `button.innerHTML` and restores it in `finally`. `refreshMe()` is -invoked from the SSE handler, the poll chain, `placeBet`, `withdraw` and `showDashboard`, -all sharing `#refresh-btn`. Two overlapping calls make the second snapshot the *loading* -label, which it then restores permanently — leaving the button stuck on -"Aggiornamento…". - -*Proposed fix:* keep the in-flight promise on the element itself (e.g. a -`button._loadingPromise` / `WeakMap`); a second call either awaits the existing one or -returns immediately, so only the outermost call restores the markup. Also worth -de-duplicating the SSE burst: `onRoundServerEvent` fires three fetches per event, and the -broadcaster is generic, so every client reacts to every event. - -*Test:* manual — trigger a bet while an SSE-driven `refreshMe()` is in flight and confirm -the refresh button returns to its icon+label state. - -**Fixed.** `withLoading` keeps the in-flight promise in a `WeakMap` keyed by the -button: a nested call awaits the existing one and runs its own work without touching the -markup, so only the outermost call restores it. Verification is manual (browser -interaction). - -### B-24 - -**The global exception handler breaks the error contract** - -*File:* `app/main.py:74-77` - -It answers `{"detail": "internal server error"}` — a bare string, while -`app/api/errors.py` documents `detail` as `{"code", "message", "params"}`. The frontend -tolerates a string (`apiErrorMessage` handles that case), but the contract should be -uniform. - -*Proposed fix:* return `ApiError("internal_error", "internal server error").as_detail()` and -add `error.internal_error` to all 7 languages in `app/static/i18n.js`. Keep the response -body free of exception details — the traceback belongs in `logs/app.log` only. - -*Test:* an endpoint stubbed to raise answers 500 with `detail.code == "internal_error"` and -no exception text in the body. - -**Fixed.** The catch-all handler returns -`ApiError("internal_error", "internal server error").as_detail()`, and -`error.internal_error` was added to all 7 languages. Test: -`test_rounds_route.py::test_unhandled_errors_use_the_structured_detail_shape`, which also -asserts the exception text does not leak into the response body. - ---- - -## Cross-cutting observation - -[B-01](#b-01) through [B-04](#b-04) share one root cause: **the code treats a broadcast as -final and the network connection as never failing.** There is no reconciliation between -the database's view of the world and the chain's — no re-scan at startup, no terminal -`failed` state, no request timeouts, no "unstick" routine. Combined with the already -documented scheduler-resume gap and `restart: unless-stopped` in `docker-compose.yml`, -the system has several absorbing states from which only manual database edits recover. - -Fixing that class of problem is more valuable than any individual item above. Suggested -order of work, by expected time-to-first-occurrence in production: - -1. [B-01](#b-01) — will happen within hours of deployment. -2. [B-03](#b-03) — one bad row is enough, and it is silent. -3. [B-02](#b-02) — happens on the first bump the RBF loop actually performs. -4. [B-06](#b-06) — depends only on the user's UTXO shape. -5. [B-05](#b-05) — happens on the first operator typo, and is unrecoverable in the - foreign-address variant. -6. [B-04](#b-04) + [B-08](#b-08) — the reconciliation layer both of them need. -7. Everything else. diff --git a/CLAUDE.md b/CLAUDE.md index 765cc66..a70bbb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,233 +8,240 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 build-order stages from `/home/davide/.claude/plans/scalable-mixing-sloth.md` are code-complete and unit-tested (137 tests green): project skeleton, DB schema + Alembic migrations, auth, HD wallet derivation, Electrum client, deposit detection, bet flow, round/draw engine, payout, withdrawal, RBF fee-bump, admin config + audit log. Beyond the original 10 stages: a Docker + Caddy deployment (see below), a full admin dashboard (`/admin`), a static test UI for the user-facing flow (`/`), a pending-inclusive balance display (see "Balance display" below), and a Server-Sent Events push channel layered on top of the original polling (see "Real-time updates" below). +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. -Real-money verification on mainnet, done so far: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast, confirmed, change credited back), and a full round cycle — close → draw (real block hash) → payout (70/30 split, exact sat math verified against the broadcast tx) → confirmation → round closed → next round auto-opened. Withdrawal and the RBF bump path are unit-tested but have never been exercised against a live broadcast. See "Known gaps" below before treating this as production-ready. +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. -A full-codebase audit on 2026-07-26 found 24 bugs — five of them critical, including a -dropped Electrum connection that hung the whole server with no reconnect, an RBF fee bump -that wedged a round forever, and no way for the system to recover a broadcast that never -confirmed (funds frozen). All 24 are fixed; [BUGS.md](BUGS.md) is the record, with each -one's root cause, what was actually done, and where its regression test lives. Read it -before assuming any behaviour here predates those fixes. +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. -Before writing code, always read the "Architecture" section below in full, plus the diagrams in [flowchart/](flowchart/): [platform-overview.mmd](flowchart/platform-overview.mmd) for the whole 5-phase flow, and [round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw lifecycle in detail. Every node in these diagrams corresponds to a behavior that must be implemented exactly as described, including the labels on the edges (conditions, retries, loops). Regenerate their companion PDFs with `flowchart/render-pdf.sh .mmd` after editing either one. +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 .mmd` after editing either. -Human-facing guides live in [docs/](docs/) (Italian, per explicit request — an exception to this file's English-only rule below): [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). +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). ## Commands -The server itself — in development and in production alike — always runs via Docker (see "Deployment" below); there is no supported way to run `uvicorn` directly against this codebase. The venv (`.venv/`) is only for local tooling: running tests, authoring Alembic migrations, and running the one-time scripts that generate the secrets/key material that end up referenced from `.env`. +The server always runs via Docker, in dev and prod alike — there is no supported way to run `uvicorn` directly. The venv (`.venv/`) is only for local tooling: tests, Alembic migrations, and the one-time key/secret scripts. ```bash -source .venv/bin/activate # venv already created at .venv/ -pip install -e ".[dev]" # install/update deps +source .venv/bin/activate # venv already created at .venv/ +pip install -e ".[dev]" -alembic revision --autogenerate -m "message" # generate a new migration after editing app/db/models.py (applied automatically by the container's startup command — see Deployment — never run `alembic upgrade head` manually) +alembic revision --autogenerate -m "message" # after editing app/db/models.py; the container applies it at startup — never run `alembic upgrade head` by hand -PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the server's master xprv (requires XPRV_ENCRYPTION_KEY in .env; see Deployment for where MASTER_KEY_PATH should point) -PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print the existing master xprv (asks for confirmation first) -PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: bring your own externally-generated xprv instead of generating one (getpass prompt, --overwrite to replace) +PYTHONPATH=. python scripts/generate_master_key.py # one-time: create+encrypt the master xprv (needs XPRV_ENCRYPTION_KEY in .env) +PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+print it (asks for confirmation) +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 # run all tests -python -m pytest tests/unit/test_hd.py # run one test file -python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # run a single test +python -m pytest # all 253 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 ``` -`.env` (gitignored) holds real secrets for local dev; `.env.example` documents the required keys and how to generate them. +`asyncio_mode = "auto"` (`pyproject.toml`), so async tests need no `@pytest.mark.asyncio`. + +`.env` (gitignored) holds the real secrets; `.env.example` documents the required keys and how to generate each. Note it does **not** list `DATABASE_URL` or `MASTER_KEY_PATH`, which the real `.env` does set. ## Deployment (Docker + Caddy) -The app is always run via Docker — dev and prod alike use the same `docker-compose.yml`, just with a different `SITE_ADDRESS` (see below); there's no separate dev-mode compose file or bare-`uvicorn` workflow. `docker-compose.yml` runs two containers: `app` (this codebase, built by `Dockerfile`, runs `alembic upgrade head` then `uvicorn`) and `caddy` (reverse proxy + automatic TLS). `.env` holds the app secrets; `docker-compose.yml` overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted master key, logs — all gitignored, persist across container restarts). Set `MASTER_KEY_PATH` in `.env` itself to the host-side equivalent, `./data/keys/master.xprv.enc`, so the venv-run key-generation scripts above (see "Commands") write to the exact same file the container reads — one source of truth for the key, whichever way it was generated. +Same `docker-compose.yml` for dev and prod — only `SITE_ADDRESS` differs. Two containers: `app` (this codebase; its startup command refuses to start if the master key file is missing, then runs `alembic upgrade head` and `uvicorn`) and `caddy` (reverse proxy + automatic TLS). The compose file overrides `DATABASE_URL`/`MASTER_KEY_PATH` inside the container to point at the bind-mounted `./data/` (db, encrypted key, logs — gitignored, survive restarts). Set `MASTER_KEY_PATH` in `.env` to the host-side `./data/keys/master.xprv.enc` so the venv scripts write the exact file the container reads — one source of truth for the key. ```bash -mkdir -p data/db data/keys data/logs # one-time: host dirs bind-mounted into the app container - -# one-time: generate the master key via the venv script above (scripts/generate_master_key.py), -# not via `docker compose run` — MASTER_KEY_PATH in .env already points at ./data/keys/ - -docker compose up -d --build # build + start app and caddy — same command for dev and prod -docker compose logs -f app # tail app logs (also written to ./data/logs/app.log) -docker compose down # stop +mkdir -p data/db data/keys data/logs # one-time +docker compose up -d --build # dev and prod alike +docker compose logs -f app # also written to ./data/logs/app.log +docker compose down ``` -Caddy's site address comes from `SITE_ADDRESS` (env var on the host, read by `docker-compose.yml`): -- **Dev, no domain**: leave it unset (defaults to `localhost`). Caddy detects it isn't a public hostname and issues a self-signed cert from its own internal CA — browsers will warn on first visit, expected for local testing (`curl -k` or click through). -- **Production, with a domain**: `SITE_ADDRESS=lottery.example.com docker compose up -d` (DNS must already point at the server, ports 80+443 reachable). Caddy automatically requests and renews a real Let's Encrypt certificate — no other config needed. +`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). -Known risk: `docker-compose.yml` sets `restart: unless-stopped` on `app`, so a crash mid-round auto-restarts the container — which hits the scheduler-resume gap below (a round stuck in `closing`/`drawing`/`paying_out` at restart stays stuck). Don't treat this as unattended-safe until that gap is closed. +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). -## Tech stack (MVP) +## Tech stack -- **Backend language**: Python. -- **PLM node access**: Electrum protocol only (no full node/P2P). Bootstrap server for development: `santantonio.sytes.net:50002` (SSL). -- **Auth**: Argon2 password hashing + JWT sessions. -- **Secrets**: master xprv encrypted at rest with a symmetric scheme (AES-GCM/Fernet); the encryption key itself lives in an env var, never in the DB or in git. -- **Operational config**: every business/round parameter (fee address, bet amount, round duration, round cooldown, draw animation duration, minimum amount, network fee rate, RBF timeout) lives in the `round_config` DB table (single row, `app/rounds/config.py`) and is only editable live via the admin dashboard (`/admin`) or its API — no env var involved at all, no redeploy or restart needed. Defaults for a brand-new instance are hardcoded column defaults on the `RoundConfig` model (`app/db/models.py`), not `app/config.py`. Secrets and infra wiring (master key, JWT secret, Electrum host, admin token, database URL) stay env-var-driven in `.env` since those genuinely need a restart. -- **Round cooldown**: `round_cooldown_seconds` — gap after a round closes before the next one opens, so players have time to see the outcome (default 30s). Not in the original flowchart; added afterwards as an explicit design decision. -- **Maintenance pause**: `RoundConfig.paused` (default `false`), toggled via `POST /admin/pause` / `POST /admin/resume` (a dedicated "Manutenzione" card in `/admin`'s Parametri section, not a plain config field — it's a deliberate operator action, audit-logged as `lottery_paused`/`lottery_resumed`). When set, `rounds/service.py:open_new_round_if_needed` stops opening a *next* round once the current one closes — it never interrupts a round already in progress (that one still closes, draws, and pays out its winner normally). `GET /rounds/current` exposes it as `lottery_paused` so the user-facing page (`/`) shows a maintenance banner. +- 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). +- 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. -## PLM network parameters +## PLM network parameters (mainnet) -Source of truth: `PalladiumWallet` repo, [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs) and [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs) — always re-check that repo if a value is needed that isn't listed here, rather than guessing. +Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumWallet/src/Core/Chain/ChainProfiles.cs), [PalladiumNetworks.cs](../PalladiumWallet/src/Core/Chain/PalladiumNetworks.cs). Re-check there for anything not listed here rather than guessing; its `ChainProfiles.Mainnet.BootstrapServers` is also where `.env.example`'s suggested `ELECTRUM_FALLBACK_SERVERS` come from. -Mainnet: -- BIP44/84 coin type: `746` (i.e. HD path `m/84'/746'/0'/0/index`) -- Bech32 HRP: `plm` -- P2PKH address version byte: `55` (addresses start with `P`) -- P2SH address version byte: `5` -- WIF prefix: `0x80` -- Block time: 120s -- BIP32 extended key headers (Legacy/native-segwit `zprv`/`zpub` etc.): see `ExtKeyHeaders` in `ChainProfiles.cs` +| | | +|---|---| +| BIP44/84 coin type | `746` → `m/84'/746'/0'/0/index` | +| Bech32 HRP | `plm` | +| P2PKH / P2SH version byte | `55` (addresses start with `P`) / `5` | +| WIF prefix | `0x80` | +| Block time | 120s | +| BIP32 ext-key headers | see `ExtKeyHeaders` in `ChainProfiles.cs` | -## Electrum connection (rotation, keepalive, timeouts) +## Business parameters -One connection serves everything — deposit credits, broadcasts, confirmations, the chain -tip the draw waits on — which makes it the platform's biggest single point of failure. -Three things keep it honest: +| Parameter | Value | Where | +|---|---|---| +| 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` | +| Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` | +| 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` | -- **Server rotation.** `ELECTRUM_HOST`/`ELECTRUM_PORT` is the primary; - `ELECTRUM_FALLBACK_SERVERS` is a comma-separated list of `host:port[:notls]` extras - (parsed by `electrum/client.py:parse_endpoints`, which rejects malformed entries at - startup rather than during the outage when the fallback is needed). The listener tries - the next server after any failed or dropped session, and only sleeps on the backoff once - every server has had a turn — so one dead server costs a single attempt, not an outage. -- **Every request is bounded** (`_REQUEST_TIMEOUT_SECONDS`, 15s) and a timeout tears the - connection down. Unbounded waits used to hang a `POST /bets` *while holding the per-user - lock*, and could stop the confirmation poller permanently. -- **The drop is observable.** `client.wait_closed()` resolves when the read loop dies, and - `listener._run_once` races it against the notification consumers and a 60s `server.ping` - keepalive. Without this the listener sat on queues nobody would ever fill again and never - reconnected — while `listener.client` still looked alive to everything else. +`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). + +**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. + +**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. + +## 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/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/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/static/` | the two SPAs (`index.html`/`app.js`/`style.css`, `admin.html`/`admin.js`/`admin.css`) + `i18n.js` | + +## Background tasks + +`app/main.py`'s lifespan starts six long-lived asyncio tasks and cancels them on shutdown. Their cadences determine how fast anything self-heals. + +| Task | File | Cadence | Role | +| --- | --- | --- | --- | +| `ElectrumListener` | `electrum/listener.py` | reconnect loop, 60s keepalive | the single connection; subscribes headers + every user's scripthash, credits deposits | +| `RoundScheduler` | `rounds/scheduler.py` | 5s | opens/closes rounds, draws, triggers and retries payouts | +| `ConfirmationPoller` | `tx/confirmation.py` | 10s | `pending` → `confirmed` via per-kind handlers registered by `app/{bets,rounds,withdrawals}/confirmation.py` — imported for that side effect in `main.py`, **don't "clean up" those imports** | +| `RbfBumper` | `tx/broadcast.py` | 30s | fee-bumps anything past `rbf_timeout_seconds` | +| `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. + +## 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. +- **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. + +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). + +**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. + +**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. + +**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`. +- *"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. +- *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. + +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. + +**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. +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`. ## Balance display -`place_bet`/`request_withdrawal` (`app/bets/service.py`, `app/withdrawals/service.py`) select whole UTXOs to cover the amount (`select_utxos`, largest-first) and mark every selected UTXO `spent_txid` immediately at broadcast time — well before the tx has any confirmations. `User.cached_balance_sats` (`recompute_balance`, `app/wallet/balance.py`) only sums confirmed, unspent UTXOs, so right after a bet/withdrawal it understates the user's real balance by the entire unconfirmed change amount, which is often far larger than the amount actually moving. +`place_bet`/`request_withdrawal` select whole UTXOs (`select_utxos`, largest-first) and mark each `spent_txid` at broadcast time, long before any confirmation. `cached_balance_sats` (`recompute_balance`) sums only confirmed, unspent UTXOs, so right after a bet it understates the real balance by the whole unconfirmed change — often far more than the amount actually moving. -`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without touching what's actually spendable: it decodes the raw tx of every in-flight (`status="pending"`) bet/withdrawal `PendingTransaction` belonging to the user and sums whichever outputs pay back to the user's own address, adding that to `cached_balance_sats`. `GET /users/me` returns both `balance_sats` (confirmed-only — still what withdrawal-max and internal spend logic use, since only confirmed UTXOs are actually spendable) and `pending_balance_sats` + `has_pending` (what the frontend displays, colored green when settled and amber while `has_pending` is true). +`compute_pending_balance` (`app/wallet/balance.py`) fixes the *displayed* number without changing what's spendable: it decodes the raw tx of every in-flight (`pending`) bet/withdrawal for the user and adds back the outputs paying to the user's own address. `GET /users/me` returns both — `balance_sats` (confirmed only; still what withdrawal-max and spend logic use, since only confirmed UTXOs are spendable) and `pending_balance_sats` + `has_pending` (what the UI shows: green when settled, amber while pending). A withdrawal whose amount is covered by the pending-inclusive balance but not the confirmed one gets `balance_pending_confirmation` instead of a flat `insufficient_balance` (B-37), so the error doesn't contradict what the user is looking at. ## Real-time updates (SSE) -`GET /rounds/stream` (`app/api/routes/rounds.py`) is a Server-Sent Events channel layered *on top of* the original polling loops in `app/static/index.html`/`admin.html` — polling is the fallback, not replaced, so a blocked/dropped SSE connection just degrades to the pre-existing behavior. The channel carries no payload and needs no auth: it's purely a "something changed, go refetch" ping; personalization (e.g. `user_played` below) still lives entirely in the normal per-user REST endpoints. +`GET /rounds/stream` is **additive to** the polling loops in the two SPAs, not a replacement — a blocked or dropped stream just degrades to the old behaviour. No payload, no auth: it's a "something changed, go refetch" ping, with all personalization (e.g. `user_played`) staying in the authenticated REST endpoints. The generator re-checks `request.is_disconnected()` every 5s and sends a keep-alive comment every 20s, so neither a client that vanished without a clean close nor a proxy idle timeout breaks it silently. -`app/rounds/events.py`'s `RoundEventBroadcaster` (module-level singleton `broadcaster`) is a simple in-process pub/sub — one `asyncio.Queue` (maxsize 1, so redundant notifications coalesce) per connected SSE client. `broadcaster.publish()` is called from every point that changes something a dashboard would want to know about: a new round opening (`rounds/service.py`), every round status transition (`rounds/scheduler.py`: closing/drawing/paying_out/closed), a bet or withdrawal broadcast (`bets/service.py`, `withdrawals/service.py`), any pending tx confirming — bet/withdrawal/payout (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new block tip arriving (`electrum/listener.py` — the exact moment the "drawing" phase is waiting on). +`rounds/events.py`'s `RoundEventBroadcaster` (singleton `broadcaster`) is in-process pub/sub, one `asyncio.Queue(maxsize=1)` per client so redundant notifications coalesce. `publish()` is called on: a round opening (`rounds/service.py`), every status transition (`scheduler.py`), a bet or withdrawal broadcast, any pending tx confirming (`tx/confirmation.py`), a deposit credited (`deposits/service.py`), and a new tip arriving (`electrum/listener.py` — exactly what the drawing phase waits on). The rollback paths (`_release_failed_bet`, `_release_failed_withdrawal`, the reconciler's abandon) publish too — a rollback moves as much state as the success path, so it must ping the dashboards the same way (B-49). -Deliberate scope decisions, not oversights: -- **Single-process only, no cross-worker fan-out.** Fine for the current deployment (one uvicorn process, see `docker-compose.yml`). A multi-worker/multi-container deployment would need a shared channel (e.g. Redis pub/sub) instead — don't add that speculatively before it's actually needed. -- **Generic broadcast, not a per-user channel.** Every connected client refetches on every event, even ones irrelevant to them. Acceptable at the expected scale (~100 concurrent users); a targeted per-user channel would need auth on the SSE endpoint and server-side knowledge of who's affected by each event — real engineering work, only worth it well past current expected concurrency. -- `MAX_SUBSCRIBERS` (default 500, `app/rounds/events.py`) is a defensive cap only — past it, `GET /rounds/stream` returns 503 instead of opening a stream, and the client's `EventSource` just falls back to polling. Not a substitute for the app-wide "no rate limiting anywhere" gap (see Known gaps). +Deliberate scope limits, not oversights: **single-process only** (fine for one uvicorn process; a multi-worker deployment needs e.g. Redis pub/sub — don't add it speculatively); **generic broadcast, not per-user** (everyone refetches on every event; acceptable at ~100 concurrent users, and a targeted channel would need auth on the stream plus server-side knowledge of who each event affects); `MAX_SUBSCRIBERS` (500) is defensive only — past it the endpoint returns 503 and `EventSource` falls back to polling, which being global and unauthenticated makes the cap itself a cheap DoS of the realtime feature (B-38). -Frontend: both `index.html` and `admin.html` open an `EventSource('/rounds/stream')` and, on an `update` message *or* on `open` (which fires on the initial connection and every automatic reconnect), immediately re-run the same refresh calls polling would eventually do — this matters most right after a dropped connection reconnects, closing most of the "missed while disconnected" gap. +Both SPAs refresh on an `update` message *or* on `open` — the latter fires on every automatic reconnect, closing most of the "missed while disconnected" gap. -## MVP business parameters +## Transaction lifecycle and reconciliation -- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant. -- Prize split: **70% winner / 30% fees**, hardcoded in `rounds/scheduler.py` (`winner_share = pool_amount_sats * 70 // 100`) — unlike bet amount, this ratio is not in `RoundConfig` and would need a code change, not an admin-panel edit. -- Minimum withdrawal amount: equal to the current bet amount (`RoundConfig.bet_amount_sats`), enforced in `app/withdrawals/service.py` — not a separate admin-configurable field. Deposits have no server-side minimum check. -- Confirmations required for all tx types (deposit, bet, payout, withdrawal): **1**, hardcoded in `tx/confirmation.py` — not configurable, per the design decision below. +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`. -## What is PLM Lottery +- `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). -A periodic-round lottery system built on a Bitcoin-like coin (PLM, mainnet). Each user gets a dedicated P2WPKH address (server-side HD wallet); they deposit PLM to that address, place a fixed-cost bet to enter the current round, and when the round closes a winner is drawn who receives 70% of the prize pool (the remaining 30% goes to fees). +`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. -## Architecture (from the flowchart subgraphs) +**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. -The flow is organized into 5 phases (see [flowchart/platform-overview.mmd](flowchart/platform-overview.mmd) for the full-platform diagram, and [flowchart/round-lifecycle.mmd](flowchart/round-lifecycle.mmd) for the round/draw phase in detail): +**"At most one active round" is a DB invariant**, not a convention: `ix_rounds_single_active` (unique index over the constant `(1)`, restricted to the active statuses) makes a concurrent second insert fail cleanly, and `open_new_round_if_needed` recovers by adopting the winner's round (max 3 attempts). -- **REG (Registration)**: on signup the server derives a new P2WPKH address via BIP84 (`m/84'/coin'/0'/0/index`, one index per user) from a master xprv **encrypted at rest**. This address is permanent and serves as both the deposit address and the address that receives winnings and withdrawals. -- **DEP (Balance top-up)**: an ElectrumClient/SPV subscribes to the user's address scripthash. Internal balance (DB) is credited after **1 confirmation only** — the reorg risk at 1-conf is knowingly accepted in v1, with no rollback logic. -- **PLAY (Bet)**: fixed cost per round, **at most one active bet per user at a time** in v1. The server builds a PSBT user-address → pool-address for the fixed amount, with a **change output back to the same user address** (the user's balance must never exactly equal the bet amount). Fee minimized (~1 sat/vB), **deducted from the bet amount**. If the tx doesn't confirm within a timeout, fee-bump (RBF) and rebroadcast. -- **DRAW (Periodic draw)**: configurable timer (default 10 minutes). The round's own deadline (`opened_at + round_duration_seconds`) is the authoritative "yellow light" cutoff for new bets — **not** the DB status transition. `place_bet` (`app/bets/service.py`) calls `rounds/service.round_accepts_bets(round_, round_duration_seconds)`, which rejects the bet once the deadline has passed even if `status` is still `"open"` in the DB (the `RoundScheduler` tick that flips it to `"closing"` runs every `_TICK_INTERVAL_SECONDS` = 5s and can lag a few seconds behind the deadline). This closes the race where a bet placed in that lag window would otherwise still be accepted. Once a round leaves `open` (closing/drawing/paying_out), **no new bets are accepted** for it either, and a new round can't open until the current one is fully `closed` (see round cooldown below). Round closing **waits for all already-broadcast bets to confirm** before proceeding (avoids losing bets at the round boundary) — this is the "yellow light" behavior: no new entries once the timer hits zero, but bets already in flight are still given time to confirm before the round actually closes and draws. The **next round only opens once the previous round's payout tx is confirmed** — rounds never overlap in v1. v1 draw algorithm (deliberately simple, meant to be replaced later): wait for the first block confirmed after round closing, use its hash as seed, `index = seed mod participant_count` over the participant list ordered by **broadcast timestamp** (this is also the tie-break when two bets confirm in the same block). Every participant has **equal probability regardless of bet amount** (consistent with the fixed bet amount). The payout (70% winner / 30% fees) is signed with the pool address key; the **payout fee is deducted from the winner's 70%**, the 30% fee share stays intact. Same timeout → RBF → rebroadcast pattern here too. The frontend shows a generic "drawing" status box (phase label, e.g. "Pagamento al vincitore in corso…") to **every** viewer on every dashboard for the whole closing/drawing/paying_out phase — this one is purely cosmetic status text, driven directly by `status`, no gating. Independently and *additively* (not instead of it), a personalized "Hai vinto!/Non hai vinto" box appears only for users where `GET /rounds/current`'s `user_played` field is true (computed via `app/auth/dependencies.py:get_optional_user`, since this endpoint is reachable logged-out too) — everyone else has nothing to reveal and never sees it. That reveal is additionally delayed by at least `draw_animation_seconds` (admin-configurable, default 20s) for cosmetic suspense, anchored to the round's server-provided `closes_at` timestamp rather than a client-side "first seen" time (so reloading the page can't reset the countdown), and decoupled from the real (and much longer, ~block-time) wait for `winner_user_id` to actually be set. Once revealed, the result is persisted in the browser's `localStorage` (`plm_persisted_result`) so it survives a page refresh even after the round moves past `paying_out` into `closed` — at which point `get_active_round` stops returning that round at all and `winner_user_id` disappears from `GET /rounds/current` entirely. `GET /users/me/last-round-result` (`app/api/routes/users.py`) is a durable, DB-backed backstop for a user who reloads on a browser/device that missed the live reveal window completely: it looks up the most recent *closed* round the user has a `RoundParticipant` row in. See `app/static/index.html`'s `refreshRound`/`checkLastRoundResult` for the full reveal logic. -- **WITHDRAW (Withdrawal)**: the only way to move funds out of the platform to an external address. PSBT user-address → external-address + change back to the user address, fee deducted from the withdrawn amount, same RBF retry pattern. +## Frontends -PLAY and WITHDRAW share a **per-user DB lock**: a user can never have a bet-build and a withdrawal-build in flight at the same time, since both would otherwise spend from the same UTXO set on the user's dedicated address. +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`. -**Three separate on-chain confirmations, not one, between the timer hitting zero and the payout landing** — a common point of confusion, worth spelling out explicitly: -1. **Last bet's confirmation** (`scheduler.py`'s `_tick`, the `pending_count` check before `_close_and_draw`) — the round doesn't even flip to `"closing"` until every already-broadcast bet has its 1st confirmation. This can already have happened before the timer expired; it's the earliest of the three and not necessarily tied to the deadline at all. -2. **The draw block** (`_wait_for_next_block`, waits for `tip_height > tip_at_close`, where `tip_at_close` is recorded only once step 1 is done) — by construction this must be a **later, different block** than whichever one confirmed the last bet in step 1. -3. **Payout confirmation** — `_trigger_payout` broadcasts only after step 2's block is known, then registers a `PendingTransaction(kind="payout")` that the same generic `ConfirmationPoller` (`app/tx/confirmation.py`) waits on independently — this needs **yet another, later block** than step 2's, since the payout can't be built before the winner is known. +- **`/`** — 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. -So worst case (last bet confirms right at the deadline) is ~3 block times end-to-end; best case (all bets already confirmed before the timer hit zero) is ~2 (draw block + payout block). At PLM's 120s block time that's roughly 4–6 minutes worst case, 2–4 minutes best case — independent of `draw_animation_seconds`, which only sets a cosmetic minimum for the frontend animation. +Both talk to the same JSON API; there's no admin/user API split beyond `require_admin`. -## Internationalization (user-facing page only) +## Internationalization (`/` only) -`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is available everywhere. Language comes from `localStorage.plm_lang`, falling back to `navigator.language`, falling back to `en`; the switcher lives in the **chain-bar, not the navbar**, deliberately — the navbar is hidden until login, which would leave the landing page and the login form untranslatable for exactly the users who need the switch. +`app/static/i18n.js` holds every user-facing string of `/` in 7 languages (en, it, es, fr, de, ru, zh) as one flat `TRANSLATIONS` table — no build step, no fetch, loaded before `app.js` so `t()` is always available. Language: `localStorage.plm_lang` → `navigator.language` → `en`. The switcher sits in the **chain-bar, not the navbar**, deliberately: the navbar is hidden until login, which would leave the landing page and login form untranslatable for exactly the users who need it. -- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`), applied by `applyStaticTranslations(root?)` on `DOMContentLoaded` and on every switch. Anything rendered from server data is built with `t()` in `app.js` instead, and re-rendered by `onLanguageChange()` — an element must be in one camp or the other, never both, or the two mechanisms overwrite each other (this is why `#bet-btn` has no `data-i18n`: its label carries the admin-configurable bet amount, so `renderBetButton()` owns it). -- **Every language must have exactly the same key set.** There is no fallback beyond `en`, and a missing key renders as the raw key string. -- `/admin` is intentionally **not** translated (operator-facing, Italian only), and neither is `/guida` (serves `docs/guida-utente.md`). +- Static markup is translated by attribute (`data-i18n`, plus `-html`, `-placeholder`, `-title`, `-aria-label`, `-alt`) via `applyStaticTranslations(root?)`; anything rendered from server data uses `t()` in `app.js` and is re-rendered by `onLanguageChange()`. An element belongs to one camp or the other, **never both**, or the two mechanisms overwrite each other — that's why `#bet-btn` has no `data-i18n`: its label carries the configurable bet amount, so `renderBetButton()` owns it. +- **Every language must have exactly the same key set.** There is no fallback beyond `en`; a missing key renders as the raw key string. +- `/admin` is intentionally untranslated (operator-facing, Italian), as is `/guida`. -**API error contract** (`app/api/errors.py`): the API is single-language by design. User-facing failures answer with a structured `detail` — `{"code", "message", "params"}` — where `message` is English for non-dashboard consumers and `code` is what the frontend maps onto `error.` in `i18n.js` (falling back to `message` for an unknown code). Domain exceptions (`BetError`, `WithdrawalError`) subclass `ApiError` and carry the code from where the failure actually happens; `str(exc)` is still the English message. When adding a user-facing error: give it a code, add `error.` to all 7 languages, and pass interpolated values through `params` (amounts as `*_sats` — the frontend derives a `*_plm` sibling automatically) rather than baking them into the English text. - -## Admin dashboard and test UI - -Two static single-page apps, served directly by FastAPI (`app/main.py` mounts `app/static/` and adds a dedicated `GET /admin` route) — no build step, no framework. Each page's HTML/CSS/JS are separate files (`index.html`/`style.css`/`app.js`, `admin.html`/`admin.css`/`admin.js`), served as plain static files (no bundler): - -- **`/` (`app/static/index.html`)**: the end-user test UI. Register/login, then a menu-driven dashboard (Deposito with a QR code of the address via `GET /qr/{address}`, Bet, Prelievo) with a persistent round-status card (`GET /rounds/current`: id/status/timer/participant count/jackpot) above the menu. -- **`/admin` (`app/static/admin.html`)**: gated by a token screen (not a real login — just checks `X-Admin-Token` against `ADMIN_TOKEN` from `.env`), then a navbar-driven dashboard with five sections, each backed by its own `/admin/*` endpoint (`app/api/routes/admin.py`): Parametri (`RoundConfig` CRUD), Utenti (list + per-user WIF privkey export, audit-logged), Round (history), Transazioni pendenti (in-flight RBF candidates), Audit log. **`/admin` is deliberately not linked from `/`** in either direction — reachable only by knowing the URL. - -Both pages talk to the same JSON API everything else uses; there's no separate "admin API" vs "user API" boundary beyond the `require_admin` dependency. +**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.` 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.` 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 -These choices were made explicitly during design (not derivable from reading a single file) and must be respected in any implementation: +Explicit design choices, not derivable from any single file — respect them: -- Private keys (xprv) are generated and held **server-side** — this is not a non-custodial system: the user never controls their own keys until they make an explicit withdrawal. -- The user's personal deposit address always doubles as the winnings-receiving address: there is no separate "winner address". -- 1 confirmation is the chosen threshold for all tx types (deposits, bets, payouts, withdrawals): don't introduce different thresholds (e.g. 3 or 6 confirmations) without an explicit decision. -- The draw algorithm (node R) is deliberately simple and should be treated as a replaceable/pluggable component, not the final design — don't architect around its current implementation. -- The admin panel can export any user's raw WIF private key (`GET /admin/users/{id}/privkey`, `app/wallet/hd.py:derive_user_wif`). This is intentional, not a vulnerability to fix: the server already holds the master key everything derives from (custodial by design, see above), so this only exposes through the API something an operator could already do via a script. Every access is written to `audit_log` (`admin_privkey_accessed`) — don't remove that logging when touching this endpoint. -- RBF fee bumps are paid by whoever's change output the tx pays back to — the user for bets/withdrawals, the pool for payouts — never by the fixed counterparty amount (recipient/winner/fee-address outputs are untouched; only the sender's own change shrinks). See `bump_fee` in `app/tx/broadcast.py`. - -## Transaction reconciliation and the tx lifecycle - -Everything that spends money is written **before** it is broadcast, and resolved against -the chain afterwards. This is what makes the system recover on its own instead of needing -manual DB edits (BUGS.md B-04/B-08). - -`PendingTransaction.status` is the lifecycle: `building` → `pending` → `confirmed`, or -`failed`. - -- `building` is written first, with the UTXOs already marked `spent_txid`, and committed - *before* the broadcast (`bets/service.py:place_bet`, `withdrawals/service.py`). A crash - in that window therefore leaves evidence rather than coins spent on-chain with no record. -- If the broadcast is refused, the service releases the reserved UTXOs, restores the - balance, removes the participant (or marks the withdrawal `failed`), audit-logs it, and - raises `broadcast_failed` — answered as **502**, since the network refused it, not the - caller. -- `app/tx/reconcile.py` (`PendingTransactionReconciler`, every 120s and once at startup) - asks the chain about anything still `building`/`pending`. Tx present → promote; tx gone → - mark `failed` with a `failure_reason`, release the inputs, roll the domain row back, - audit-log `pending_tx_abandoned`. Grace periods differ by state (120s for `building`, - 6h for `pending`, so the RBF bumper gets its attempts first), and a *transport* failure - never abandons anything — only a server that positively doesn't know the tx does. - -Because of this, `UtxoEvent.spent_txid` must always equal the *current* txid of the tx -reserving it: `bump_fee` retargets it (along with `RoundParticipant.bet_txid`, -`Withdrawal.txid` and `Round.payout_txid`) on every fee bump. Confirmation handlers -deliberately key off immutable ids (`round_id`/`user_id`, `withdrawal_id`) rather than the -txid, which changes under them. - -**At most one active round is a database invariant**, not just a code convention: -`ix_rounds_single_active` (a unique index over the constant expression `(1)`, restricted to -the active statuses) makes a concurrent second insert fail cleanly, and -`open_new_round_if_needed` recovers by using the winner's round. +- Keys are generated and held **server-side**: this is **custodial**. The user controls nothing until they withdraw. +- The deposit address *is* the winnings address — there is no separate "winner address". +- **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. +- 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`). ## Known gaps / TODO -Not blockers for reading the code, but must be addressed before this is production-ready. -The 24 findings of the 2026-07-26 full-codebase audit are **all fixed** — see -[BUGS.md](BUGS.md), which keeps each one's root cause, fix and regression test as the -record. What remains open: +Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here. -- **Scheduler doesn't resume mid-flight rounds after a restart.** `rounds/scheduler.py`'s `_tick()` only acts on rounds with `status == "open"`. If the process restarts while a round is `closing`/`drawing`/`paying_out`, it's permanently stuck — nothing re-enters `_wait_for_next_block` or retries `_trigger_payout`. Needs a startup routine that inspects in-progress rounds and resumes (or a periodic "unstick" check) before this can run unattended. Note this is *round*-level state: in-flight *transactions* do now recover on their own (see "Transaction reconciliation" below). -- **RBF bump only handles one case**: a single change output, paying back to the tx's own sender address, large enough to absorb the fee increase. No additional-input selection fallback — an exact-amount tx (no change) or a change output too small to absorb the bump raises `RbfError`. The consequence is no longer permanent, though: a tx that can't be bumped and never confirms is eventually abandoned and its UTXOs released (see "Transaction reconciliation"), so the funds come back instead of being frozen. -- **Payout retry**: if `_trigger_payout` fails (insufficient pool UTXOs, a bad `fee_address`, Electrum disconnected), it logs, writes a `payout_failed` audit entry, and returns — the round stays in `paying_out` with no automatic retry. The audit entry makes it visible in `/admin`; acting on it is still manual. -- **Withdrawal and RBF bump have never been exercised against a live broadcast** — only deposit and bet flow are verified end-to-end with real PLM. Both paths have unit coverage, including their failure and rollback branches, but unit tests are not a live network. -- **No general user-facing history endpoints** (list my own bets / withdrawals / past rounds) — `GET /users/me/last-round-result` covers exactly one case (the outcome of the most recent *closed* round the user played in, as a reveal-persistence backstop; see DRAW above), not a real history. The admin side has more (`/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`), but there's still no "my own full history" equivalent for a logged-in user. A failed withdrawal now leaves a `status="failed"` row the user cannot see anywhere — an argument for closing this gap. -- **Admin auth is a single shared bearer token** (`ADMIN_TOKEN`, `X-Admin-Token` header) — no per-admin identity: `audit_log` records *what* changed (config edits are now logged too, as `config_updated`, with before/after values) but never *which operator* did it. This token gates the user list, private key export and round/audit history, so its blast radius if leaked is large. -- **No rate limiting / abuse protection** on any endpoint (register, bet, withdrawal, admin). -- **`/guida` is not served in Docker.** `GET /guida` reads `docs/guida-utente.md`, and the `Dockerfile` deliberately does not `COPY docs` — the guide is pending a rewrite, so it isn't shipped yet. The endpoint answers a clean 404 (`guide_unavailable`, translated) and logs an error rather than crashing, but the navbar help link leads nowhere until `COPY docs ./docs` is added back. -- No automated integration tests against a live Electrum connection — all live-network verification so far has been manual (ad hoc scripts + real mainnet transactions), not part of the `pytest` suite. -- **Single-process assumptions**: the SSE broadcaster (`rounds/events.py`) and the per-user locks (`tx/locks.py`) are both in-process only. Fine for the current one-uvicorn-process deployment; a multi-worker one needs a shared channel and a DB/Redis lock. Note the round-uniqueness invariant is *not* in this category any more — it's enforced by a DB index (see below). +- **`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. +- **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. +- **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 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. diff --git a/Caddyfile b/Caddyfile index b51229c..f6db413 100644 --- a/Caddyfile +++ b/Caddyfile @@ -13,5 +13,21 @@ not path /rounds/stream } encode @not_sse gzip + + # B-43: Caddy adds none of these on its own. The JWT lives in + # localStorage, so any XSS exfiltrates it — CSP is the main mitigation. + # script-src/style-src need 'unsafe-inline' because both SPAs + # (app/static/index.html, admin.html) use inline onclick handlers and + # style="" attributes throughout; removing those is a separate, + # larger refactor, not a header change. fonts.googleapis.com/gstatic.com + # are the one external asset (the Google Fonts @import in style.css/admin.css). + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + 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 } diff --git a/README.md b/README.md index f710ec9..cc21160 100644 --- a/README.md +++ b/README.md @@ -13,31 +13,30 @@ production-ready. ## Quick start +The server always runs via Docker (app + Caddy reverse proxy with automatic +TLS) — in dev and production alike, with only `SITE_ADDRESS` differing +between the two. There's no supported way to run `uvicorn` directly; the +venv is only for local tooling (tests, Alembic migrations, the one-time key +scripts) — see [CLAUDE.md](CLAUDE.md#commands). + ```bash cp .env.example .env # then fill in the generated secrets, see docs/setup.md -python3 -m venv .venv && source .venv/bin/activate -pip install -e ".[dev]" -PYTHONPATH=. python scripts/generate_master_key.py -alembic upgrade head -uvicorn app.main:app --reload --port 8123 -``` - -Open `http://127.0.0.1:8123/` for the test UI, `http://127.0.0.1:8123/admin` -for the admin dashboard, `http://127.0.0.1:8123/docs` for the interactive API -docs. - -Or run the whole stack (app + Caddy reverse proxy with automatic TLS) via -Docker: - -```bash mkdir -p data/db data/keys data/logs docker compose run --rm app python scripts/generate_master_key.py docker compose up -d --build ``` +Open `https://localhost/` for the test UI, `https://localhost/admin` for the +admin dashboard (a self-signed-certificate warning on first visit is +expected in dev — accept it, or use `curl -k`). The interactive API docs at +`/docs` are disabled by default (they'd otherwise expose the whole API +surface, admin endpoints included) — set `ENABLE_API_DOCS=true` in `.env` to +enable them. + See [docs/setup.md](docs/setup.md) and [docs/running-the-server.md](docs/running-the-server.md) for the full -walkthrough (both workflows, dev vs. production TLS). +walkthrough (secrets, master key generation, production TLS with a real +domain). ## Documentation @@ -62,7 +61,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -76 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +232 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 diff --git a/app/api/client_ip.py b/app/api/client_ip.py new file mode 100644 index 0000000..0d64756 --- /dev/null +++ b/app/api/client_ip.py @@ -0,0 +1,17 @@ +from fastapi import Request + + +def client_ip(request: Request) -> str: + """The caller's real IP, from Caddy's X-Forwarded-For (see Caddyfile) — + request.client.host would otherwise be the reverse proxy's own address, not + the caller's. Falls back to request.client.host only if the header is + somehow missing (e.g. the app container hit directly, bypassing Caddy). + + 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". + """ + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 11b3db5..6651485 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,11 +1,12 @@ import json import secrets -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select 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.config import settings @@ -14,6 +15,7 @@ from app.db.session import get_session from app.rounds.config import get_round_config 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 router = APIRouter(prefix="/admin", tags=["admin"]) @@ -24,7 +26,9 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None: # anyone on an instance that never configured a token. if not settings.admin_token: raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") - if not secrets.compare_digest(x_admin_token, settings.admin_token): + # compare_digest raises TypeError on a str containing non-ASCII characters + # (B-46) -- comparing the UTF-8 bytes instead accepts any input safely. + if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()): raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") @@ -68,7 +72,7 @@ class RoundConfigUpdate(BaseModel): bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000) round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600) round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600) - fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=10_000) + 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) @@ -162,7 +166,7 @@ async def list_users(session: AsyncSession = Depends(get_session)) -> list[Admin username=u.username, address=u.address, balance_sats=u.cached_balance_sats, - created_at=u.created_at.isoformat(), + created_at=isoformat_utc(u.created_at), ) for u in users ] @@ -214,6 +218,11 @@ async def reset_user_password( new_password = secrets.token_urlsafe(12) user.password_hash = hash_password(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 + # existing token until it naturally expired, unaffected by the reset. + user.token_version += 1 await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id) await session.commit() return AdminPasswordResetResponse(username=user.username, new_password=new_password) @@ -235,7 +244,9 @@ class AdminRoundResponse(BaseModel): @router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) -async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = 50) -> list[AdminRoundResponse]: +async def list_rounds( + session: AsyncSession = Depends(get_session), limit: int = Query(default=50, ge=1, le=500) +) -> list[AdminRoundResponse]: rounds = (await session.scalars(select(Round).order_by(Round.id.desc()).limit(limit))).all() winner_ids = {r.winner_user_id for r in rounds if r.winner_user_id is not None} winners = {} @@ -247,8 +258,8 @@ async def list_rounds(session: AsyncSession = Depends(get_session), limit: int = AdminRoundResponse( id=r.id, status=r.status, - opened_at=r.opened_at.isoformat(), - closed_at=r.closed_at.isoformat() if r.closed_at else None, + opened_at=isoformat_utc(r.opened_at), + closed_at=isoformat_utc(r.closed_at), draw_block_height=r.draw_block_height, draw_block_hash=r.draw_block_hash, winner_user_id=r.winner_user_id, @@ -275,7 +286,7 @@ class AdminAuditLogResponse(BaseModel): "/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)] ) async def list_audit_log( - session: AsyncSession = Depends(get_session), limit: int = 200 + session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500) ) -> list[AdminAuditLogResponse]: entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all() return [ @@ -285,7 +296,7 @@ async def list_audit_log( payload=json.loads(e.payload_json), user_id=e.user_id, round_id=e.round_id, - created_at=e.created_at.isoformat(), + created_at=isoformat_utc(e.created_at), ) for e in entries ] @@ -312,10 +323,13 @@ class AdminPendingTransactionResponse(BaseModel): ) async def list_pending_transactions( session: AsyncSession = Depends(get_session), + limit: int = Query(default=50, ge=1, le=500), + status_filter: str | None = Query(default=None, alias="status"), ) -> list[AdminPendingTransactionResponse]: - entries = ( - await session.scalars(select(PendingTransaction).order_by(PendingTransaction.id.desc())) - ).all() + query = select(PendingTransaction).order_by(PendingTransaction.id.desc()) + if status_filter is not None: + query = query.where(PendingTransaction.status == status_filter) + entries = (await session.scalars(query.limit(limit))).all() return [ AdminPendingTransactionResponse( id=p.id, @@ -327,7 +341,7 @@ async def list_pending_transactions( current_txid=p.current_txid, fee_rate_sat_vb=p.fee_rate_sat_vb, attempt_count=p.attempt_count, - broadcast_at=p.broadcast_at.isoformat(), + broadcast_at=isoformat_utc(p.broadcast_at), replaced_by_txid=p.replaced_by_txid, ) for p in entries diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 7445dea..69caf80 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -8,11 +8,13 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.client_ip import client_ip +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.session import get_session from app.rounds.config import get_round_config -from app.rounds.events import RoundEventCapacityError, broadcaster +from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster from app.rounds.service import get_active_round router = APIRouter(prefix="/rounds", tags=["rounds"]) @@ -45,9 +47,14 @@ async def round_stream(request: Request) -> Response: That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this returns 503 rather than opening a stream, and the browser's EventSource just retries later while the frontend keeps working off polling meanwhile. + + Concurrent streams are additionally capped per client IP (B-38): past + MAX_SUBSCRIBERS_PER_IP, opening one more evicts that IP's own oldest + connection rather than refusing the new one or letting a single source + exhaust the global cap and degrade every other user. """ try: - queue = broadcaster.subscribe() + queue = broadcaster.subscribe(client_ip(request)) except RoundEventCapacityError: return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"}) @@ -58,7 +65,9 @@ async def round_stream(request: Request) -> Response: if await request.is_disconnected(): break try: - await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS) + item = await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS) + if item is EVICTED: + break # this IP opened another stream past its per-IP cap yield "event: update\ndata: {}\n\n".format(json.dumps({})) ticks_since_keepalive = 0 except asyncio.TimeoutError: @@ -90,6 +99,10 @@ class CurrentRoundResponse(BaseModel): winner_amount_sats: int | None = None draw_block_height: int | None = None draw_block_hash: str | None = None + # B-36: set only while status == "drawing", so the frontend can show "still + # 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 chain_tip_height: int | None = None lottery_paused: bool = False user_played: bool = False @@ -168,6 +181,7 @@ async def current_round( 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, chain_tip_height=chain_tip_height, lottery_paused=config.paused, user_played=user_played, diff --git a/app/api/routes/users.py b/app/api/routes/users.py index f467f92..a5d3758 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -4,8 +4,9 @@ from sqlalchemy import select 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, hash_password, verify_password +from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password from app.db.models import Round, RoundParticipant, User from app.db.session import get_session from app.wallet.balance import compute_pending_balance @@ -36,7 +37,7 @@ async def me( balance_sats=user.cached_balance_sats, pending_balance_sats=pending_balance_sats, has_pending=has_pending, - created_at=user.created_at.isoformat(), + created_at=isoformat_utc(user.created_at), ) @@ -45,12 +46,16 @@ class ChangePasswordRequest(BaseModel): new_password: str -@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT) +class ChangePasswordResponse(BaseModel): + access_token: str + + +@router.post("/me/change-password", response_model=ChangePasswordResponse) async def change_password( body: ChangePasswordRequest, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), -) -> None: +) -> ChangePasswordResponse: """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).""" @@ -67,7 +72,15 @@ async def change_password( ) user.password_hash = hash_password(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 + # handed back so *this* session keeps working without forcing a re-login; + # every other open session (this user's other devices, or an attacker's) + # gets "session_expired" on its next request. + user.token_version += 1 await session.commit() + return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version)) class LastRoundResultResponse(BaseModel): diff --git a/app/api/timeutil.py b/app/api/timeutil.py new file mode 100644 index 0000000..8f17e81 --- /dev/null +++ b/app/api/timeutil.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + + +def isoformat_utc(dt: datetime | None) -> str | None: + """Serialize a datetime for API responses, stamping it UTC first. + + Every DateTime column is written via app.db.models.utcnow() but SQLite/aiosqlite + round-trips it as a naive datetime, so a bare .isoformat() drops the "Z"/offset + and JavaScript's `new Date()` on the frontend parses the result as local time + instead of UTC (B-35). All stored values are UTC in practice, so a naive value + can be safely stamped rather than converted. + """ + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.isoformat() diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 04503f4..a44a15a 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -16,13 +16,19 @@ async def get_current_user( session: AsyncSession = Depends(get_session), ) -> User: try: - user_id = decode_access_token(credentials.credentials) + user_id, token_version = decode_access_token(credentials.credentials) except Exception as exc: raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "invalid token") from exc user = await session.scalar(select(User).where(User.id == user_id)) if user is None: raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "user not found") + if user.token_version != token_version: + # B-34: a password change (self-service or admin reset) bumps + # token_version, so a token issued before it — including one an + # attacker who had the old password is still holding — reads as + # expired rather than staying valid until it naturally times out. + raise http_error(status.HTTP_401_UNAUTHORIZED, "session_expired", "token has been superseded") return user @@ -37,7 +43,10 @@ async def get_optional_user( if not auth_header.startswith("Bearer "): return None try: - user_id = decode_access_token(auth_header.removeprefix("Bearer ")) + user_id, token_version = decode_access_token(auth_header.removeprefix("Bearer ")) except Exception: return None - return await session.scalar(select(User).where(User.id == user_id)) + user = await session.scalar(select(User).where(User.id == user_id)) + if user is None or user.token_version != token_version: + return None + return user diff --git a/app/auth/rate_limit.py b/app/auth/rate_limit.py new file mode 100644 index 0000000..becd568 --- /dev/null +++ b/app/auth/rate_limit.py @@ -0,0 +1,75 @@ +import time +from dataclasses import dataclass + + +@dataclass +class _Bucket: + failures: int = 0 + locked_until: float = 0.0 + last_failure_at: float = 0.0 + + +class RateLimiter: + """In-process failed-attempt throttle with exponential backoff, keyed by an + arbitrary string (username, IP...). Single-process-only, like UserLocks + (app/tx/locks.py) — an accepted MVP constraint; a multi-worker deployment + would need a shared store (Redis) instead (B-33). + + Brute-forcing a login here isn't a spammy client to be capped at N req/s — + it's an attempt to withdraw someone else's funds — so failures are + penalized with a delay that doubles each time past `threshold` free + attempts, rather than a flat rate cap. `decay_seconds` ages a bucket back + to zero once failures stop, so a shared/NAT IP isn't punished forever for + someone else's earlier mistakes. + """ + + def __init__( + self, + threshold: int = 5, + base_delay: float = 2.0, + max_delay: float = 300.0, + decay_seconds: float = 900.0, + ) -> None: + self._threshold = threshold + self._base_delay = base_delay + self._max_delay = max_delay + self._decay_seconds = decay_seconds + self._buckets: dict[str, _Bucket] = {} + + 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() + return remaining if remaining > 0 else 0.0 + + def record_failure(self, key: str) -> None: + now = time.monotonic() + bucket = self._buckets.setdefault(key, _Bucket()) + if bucket.failures and now - bucket.last_failure_at > self._decay_seconds: + bucket.failures = 0 + bucket.failures += 1 + bucket.last_failure_at = now + if bucket.failures >= self._threshold: + delay = min(self._max_delay, self._base_delay * 2 ** (bucket.failures - self._threshold)) + bucket.locked_until = now + delay + + def record_success(self, key: str) -> None: + self._buckets.pop(key, None) + + +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. + + A module global would persist for the lifetime of the process — fine in + production (one app instance), but wrong in the test suite, where every + test builds its own FastAPI app against a fresh in-memory DB and expects a + clean slate; a shared global would leak failure counts between unrelated + tests. Per-`app.state` state gets a fresh instance per app automatically. + """ + + 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) diff --git a/app/auth/routes.py b/app/auth/routes.py index a744346..4fd2cd9 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -4,7 +4,9 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError 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.db.models import User from app.db.session import get_session @@ -15,6 +17,33 @@ router = APIRouter(prefix="/auth", tags=["auth"]) _MAX_REGISTER_RETRIES = 5 +def _rate_limiters(request: Request) -> AuthRateLimiters: + # B-33: no rate limiting on login was a brute-forceable path to withdrawing + # someone else's funds. Keyed per-username *and* per-IP so an attacker can't + # dodge the throttle by spraying one password across many accounts, nor by + # routing one account's guesses through many IPs alone (the username key + # 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 + # 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() + return request.app.state.auth_rate_limiters + + +def _rate_limited_error(retry_after: float): + return http_error( + status.HTTP_429_TOO_MANY_REQUESTS, + "rate_limited", + "too many attempts, try again later", + retry_after_seconds=int(retry_after) + 1, + ) + + class RegisterRequest(BaseModel): """Registration used to accept an empty username and a one-character password, while /users/me/change-password demanded 8 characters — an odd place to be @@ -34,6 +63,13 @@ class TokenResponse(BaseModel): async def register( body: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session) ) -> TokenResponse: + limiters = _rate_limiters(request) + ip_key = f"ip:{_client_ip(request)}" + 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)) if existing is not None: raise http_error(status.HTTP_409_CONFLICT, "username_taken", "username already taken") @@ -66,7 +102,9 @@ async def register( continue await session.refresh(user) request.app.state.electrum_listener.address_for_new_user(user.id, user.address) - return TokenResponse(access_token=create_access_token(user.id), address=user.address) + return TokenResponse( + access_token=create_access_token(user.id, user.token_version), address=user.address + ) raise http_error( status.HTTP_409_CONFLICT, @@ -81,8 +119,28 @@ class LoginRequest(BaseModel): @router.post("/login", response_model=TokenResponse) -async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)) -> TokenResponse: +async def login( + body: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) +) -> TokenResponse: + limiters = _rate_limiters(request) + username_key = f"user:{body.username.lower()}" + ip_key = f"ip:{_client_ip(request)}" + retry_after = max(limiters.login.retry_after(username_key), limiters.login_ip.retry_after(ip_key)) + 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): + # 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) + limiters.login_ip.record_failure(ip_key) raise http_error(status.HTTP_401_UNAUTHORIZED, "invalid_credentials", "invalid credentials") - return TokenResponse(access_token=create_access_token(user.id), address=user.address) + + # Only the username bucket resets on success — the IP bucket is left to decay + # on its own, so one correct login can't be used to wipe out an IP's failure + # count while it's mid-attack against other accounts. + limiters.login.record_success(username_key) + return TokenResponse( + access_token=create_access_token(user.id, user.token_version), address=user.address + ) diff --git a/app/auth/security.py b/app/auth/security.py index 30deb23..6b83de4 100644 --- a/app/auth/security.py +++ b/app/auth/security.py @@ -39,12 +39,20 @@ def verify_password(password: str, password_hash: str) -> bool: return False -def create_access_token(user_id: int) -> str: +def create_access_token(user_id: int, token_version: int = 0) -> str: expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) - payload = {"sub": str(user_id), "exp": expires_at} + # "tv" lets get_current_user (app/auth/dependencies.py) reject a token issued + # before the account's password was last changed (B-34): change-password and + # the admin reset both bump User.token_version, so every token that still + # carries the old value stops working immediately instead of staying valid + # for up to jwt_expire_minutes after a compromise is supposedly handled. + payload = {"sub": str(user_id), "tv": token_version, "exp": expires_at} return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) -def decode_access_token(token: str) -> int: +def decode_access_token(token: str) -> tuple[int, int]: payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) - return int(payload["sub"]) + # .get(..., 0) covers tokens issued before "tv" existed (pre-B-34 deploy) — + # they carry no claim at all, and 0 is what a freshly migrated user's + # token_version starts at, so those sessions keep working across the deploy. + return int(payload["sub"]), int(payload.get("tv", 0)) diff --git a/app/bets/service.py b/app/bets/service.py index 572f7ef..37854fa 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -62,7 +62,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: - raise BetError(exc.code, str(exc)) from exc + raise BetError(exc.code, str(exc), **exc.params) from exc # --- Phase 1: record the intent, *then* broadcast (B-08) -------------------- # Broadcasting first meant a failure (or a crash) between the broadcast and the @@ -144,6 +144,11 @@ async def _release_failed_bet( user_id=user_id, ) await session.commit() + # The rollback moved as much state as the successful path did — the balance is + # back, the participant is gone, so participant_count and jackpot shrank again. + # Without this the dashboards kept showing the phantom bet until their next poll + # (B-49); the reconciler's own abandon path has always published here. + broadcaster.publish() def _pending_transaction( diff --git a/app/config.py b/app/config.py index 58e0f78..1314ea1 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,11 @@ class Settings(BaseSettings): jwt_expire_minutes: int = 60 * 24 admin_token: str = "" + # Swagger/ReDoc/OpenAPI JSON expose the entire API surface (admin endpoints + # included) to anyone who requests them. Off by default (B-42) — set to true + # only for local development, never in production. + enable_api_docs: bool = False + # Every business/round parameter (bet amount, round duration/cooldown, # min amount, fee rate, RBF timeout, fee address) lives in the round_config # DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) — diff --git a/app/db/base.py b/app/db/base.py index cac5738..cbe56bc 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,9 +1,44 @@ -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, 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. +_SQLITE_BUSY_TIMEOUT_MS = 5000 + + +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 + while one is already active fails immediately rather than waiting at all — + realistic under this app's concurrency, and nothing previously handled it. + WAL lets readers and writers proceed without blocking each other, and + busy_timeout gives a second writer a real window to wait for the first + instead of an instant `OperationalError`. + + No-op for any dialect other than sqlite (e.g. a future PostgreSQL + DATABASE_URL), which neither needs nor understands these pragmas. + """ + if target_engine.dialect.name != "sqlite": + return + + @event.listens_for(target_engine.sync_engine, "connect") + def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}") + finally: + cursor.close() + + engine = create_async_engine(settings.database_url) +_register_sqlite_pragmas(engine) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/app/db/models.py b/app/db/models.py index 2ea24ce..f4e672f 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, ForeignKey, Index, String, UniqueConstraint, text +from sqlalchemy import BigInteger, ForeignKey, Index, String, Text, UniqueConstraint, text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -21,6 +21,12 @@ class User(Base): # Read cache only; must always be written in the same transaction as the # utxo_events rows it summarizes. Source of truth is utxo_events. cached_balance_sats: Mapped[int] = mapped_column(BigInteger, default=0) + # Embedded in every issued JWT (app/auth/security.py) and checked on every + # request (app/auth/dependencies.py:get_current_user). Bumped on a + # self-service or admin password change so every token issued before that + # point stops working immediately, instead of staying valid for up to + # jwt_expire_minutes after a compromised account's password is reset (B-34). + token_version: Mapped[int] = mapped_column(default=0, server_default="0") created_at: Mapped[datetime] = mapped_column(default=utcnow) @@ -66,6 +72,11 @@ 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) + # 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, + # since that wait has no timeout of its own — see _wait_for_next_block. + drawing_started_at: Mapped[datetime | None] = mapped_column(default=None) draw_block_height: Mapped[int | None] = mapped_column(default=None) draw_block_hash: Mapped[str | None] = mapped_column(String(64), default=None) seed_int: Mapped[str | None] = mapped_column(String(128), default=None) @@ -143,8 +154,15 @@ class PendingTransaction(Base): user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) current_txid: Mapped[str] = mapped_column(String(64)) fee_rate_sat_vb: Mapped[int] - raw_tx_hex: Mapped[str] = mapped_column(String) + raw_tx_hex: Mapped[str] = mapped_column(Text) + # The *first* broadcast — never rewritten by a bump — since this is what the + # reconciler's abandon-after-N-hours grace period (app/tx/reconcile.py) measures + # from. Bumping used to overwrite this field, which reset that clock on every + # bump and meant a repeatedly-bumped-but-never-mined tx was never abandoned + # (B-27). last_broadcast_at is the one bump_fee updates, and the one should_bump + # (app/tx/broadcast.py) reads to decide whether another bump is due. broadcast_at: Mapped[datetime] = mapped_column(default=utcnow) + last_broadcast_at: Mapped[datetime] = mapped_column(default=utcnow) status: Mapped[str] = mapped_column(String(16), default="pending") # The txid this row had *before* its most recent RBF bump (bump_fee rewrites # current_txid in place). Despite the name reading forwards, it points @@ -175,7 +193,7 @@ class AuditLog(Base): id: Mapped[int] = mapped_column(primary_key=True) event_type: Mapped[str] = mapped_column(String(32)) - payload_json: Mapped[str] = mapped_column(String) + payload_json: Mapped[str] = mapped_column(Text) user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None) round_id: Mapped[int | None] = mapped_column(ForeignKey("rounds.id"), default=None) created_at: Mapped[datetime] = mapped_column(default=utcnow) diff --git a/app/deposits/reconcile.py b/app/deposits/reconcile.py new file mode 100644 index 0000000..048d9d3 --- /dev/null +++ b/app/deposits/reconcile.py @@ -0,0 +1,69 @@ +"""Periodic safety net for deposit crediting and external-spend detection (B-30), +independent of scripthash-change notifications. + +Those notifications are the fast path, but nothing else re-verifies a user's +balance against the chain if one is ever silently lost: `address_for_new_user`'s +subscribe is best-effort (its own failure just logs, see electrum/listener.py), +and on an otherwise healthy, long-lived connection there may be no reconnect for +days — the only other event that re-subscribes everyone from scratch. Without +this, a single lost subscription meant that user's deposits were never credited, +indefinitely. + +This mirrors app/tx/reconcile.py's shape (a periodic sweep gated on the Electrum +client being connected) but reuses ElectrumListener.refresh_user directly rather +than re-implementing crediting/spend-detection, so the notification-driven and +periodic paths can never behave differently from each other. +""" + +import asyncio +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.db.models import User +from app.electrum.listener import ElectrumListener +from app.electrum.scripthash import address_to_scripthash + +logger = logging.getLogger(__name__) + +_SWEEP_INTERVAL_SECONDS = 300 + + +class DepositReconciler: + def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener): + self._session_factory = session_factory + self._listener = listener + + async def run(self) -> None: + while True: + await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) + if self._listener.client is None: + continue + try: + await self._sweep_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("deposit reconciliation sweep failed") + + async def _sweep_once(self) -> None: + """Round-robins over every user's address rather than only ones missing + from the listener's in-memory `_scripthash_to_user` map: that map can't + tell "never subscribed" apart from "subscribed, but this server silently + stopped delivering notifications for it" — exactly the failure mode this + exists to catch. One user failing (a transient network hiccup) must not + stop the sweep from reaching the rest, mirroring poll_once's per-item + isolation in tx/confirmation.py. + """ + async with self._session_factory() as session: + users = (await session.scalars(select(User))).all() + + for user in users: + if self._listener.client is None: + return # connection dropped mid-sweep; the next reconnect's own _subscribe_all_users covers everyone + scripthash = address_to_scripthash(user.address) + try: + await self._listener.refresh_user(user.id, scripthash) + except Exception: + logger.exception("deposit reconciliation failed for user_id=%s", user.id) diff --git a/app/deposits/service.py b/app/deposits/service.py index 9c3cd34..ef9a037 100644 --- a/app/deposits/service.py +++ b/app/deposits/service.py @@ -1,3 +1,5 @@ +import logging + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -6,6 +8,8 @@ from app.db.models import UtxoEvent from app.rounds.events import broadcaster from app.wallet.balance import recompute_balance +logger = logging.getLogger(__name__) + async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int: """Insert utxo_events for newly-confirmed entries from an Electrum @@ -59,18 +63,64 @@ async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: l _EXTERNAL_SPEND_SENTINEL = "external-spend" -async def detect_external_spends(session: AsyncSession, user_id: int, entries: list[dict]) -> int: - """Mirror of credit_confirmed_utxos: catches a UTXO leaving the address - through a transaction this platform never built (e.g. someone spending it - directly with the raw privkey, bypassing /withdrawals entirely). +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. + """ + current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries} - Everything the platform itself spends (bets, withdrawals, payouts) sets - spent_txid at broadcast time, before the tx ever reaches the chain — so by - the time an Electrum refresh runs, an outpoint still marked unspent in our - own DB that Electrum no longer reports as unspent was never on our radar. - entries is this address's current `listunspent`; anything in our unspent - set but missing from it left the address some other way. Returns the - number of UTXOs newly marked spent. + marked_rows = ( + await session.scalars( + select(UtxoEvent).where( + UtxoEvent.user_id == user_id, UtxoEvent.spent_txid == _EXTERNAL_SPEND_SENTINEL + ) + ) + ).all() + + reinstated = 0 + for row in marked_rows: + if (row.txid, row.vout) not in current_keys: + continue + row.spent_txid = None + await write_audit_log( + session, + "utxo_external_spend_reinstated", + {"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats}, + user_id=user_id, + ) + reinstated += 1 + + if reinstated: + await session.flush() + await recompute_balance(session, user_id) + await session.commit() + broadcaster.publish() + + return reinstated + + +async def find_utxos_missing_from(session: AsyncSession, user_id: int, entries: list[dict]) -> list[UtxoEvent]: + """Candidates for an external spend (B-29): unspent UTXOs the DB believes this + user still holds that are absent from `entries`, this address's current + listunspent. Everything the platform itself spends (bets, withdrawals, + payouts) sets spent_txid at broadcast time, before the tx ever reaches the + chain — so an outpoint still marked unspent in our own DB that Electrum no + longer reports as unspent was never on our own radar. + + Returning a row here is *not* proof it was actually spent — only that this one + server's reply no longer lists it. A single broken, behind, or malicious + server could otherwise zero a user's balance on one bad reply, which is why + the caller (electrum/listener.py:refresh_user) must independently + corroborate each candidate against other configured servers before treating + it as genuine, rather than this function marking anything itself. + + An entirely empty `entries` for an address the DB believes is funded returns + no candidates at all: it would otherwise flag every one of this user's UTXOs + as missing from a single reply, which is a strong sign of an incomplete or + broken response rather than N independent spends landing in the same refresh. """ current_keys = {(entry["tx_hash"], entry["tx_pos"]) for entry in entries} @@ -80,9 +130,32 @@ async def detect_external_spends(session: AsyncSession, user_id: int, entries: l ) ).all() - newly_spent = 0 - for row in unspent_rows: - if (row.txid, row.vout) in current_keys: + if not entries and unspent_rows: + logger.warning( + "listunspent for user_id=%s returned no entries at all while %s UTXO(s) are still recorded " + "unspent — treating this as an incomplete response rather than a full external sweep", + user_id, + len(unspent_rows), + ) + return [] + + return [row for row in unspent_rows if (row.txid, row.vout) not in current_keys] + + +async def mark_utxos_spent_externally(session: AsyncSession, user_id: int, utxo_ids: list[int]) -> int: + """Applies the external-spend sentinel to UTXOs the caller has already + corroborated against other servers (B-29) — this function does no + verification of its own, only persistence, so it never runs with a session + held open across the network calls that verification needs. + + Re-checks each row is still unspent before applying the mark: something else + may have resolved it (a legitimate platform spend, or a prior refresh) between + when the caller read the candidate list and finished corroborating it. + """ + marked = 0 + for utxo_id in utxo_ids: + row = await session.get(UtxoEvent, utxo_id) + if row is None or row.spent_txid is not None: continue row.spent_txid = _EXTERNAL_SPEND_SENTINEL await write_audit_log( @@ -91,12 +164,12 @@ async def detect_external_spends(session: AsyncSession, user_id: int, entries: l {"txid": row.txid, "vout": row.vout, "amount_sats": row.amount_sats}, user_id=user_id, ) - newly_spent += 1 + marked += 1 - if newly_spent: + if marked: await session.flush() await recompute_balance(session, user_id) await session.commit() broadcaster.publish() - return newly_spent + return marked diff --git a/app/electrum/client.py b/app/electrum/client.py index b72b28a..db4242b 100644 --- a/app/electrum/client.py +++ b/app/electrum/client.py @@ -169,6 +169,17 @@ class ElectrumClient: async def listunspent(self, scripthash: str) -> list[dict]: return await self.request("blockchain.scripthash.listunspent", [scripthash]) + async def get_history(self, scripthash: str) -> list[dict]: + """Every transaction touching `scripthash`, each as {"tx_hash", "height"} — + height > 0 means confirmed at that height, height <= 0 means still in the + mempool. Used instead of blockchain.transaction.get's verbose=True mode + for confirmation/existence checks (B-41): several Electrum server + implementations and versions reject the verbose flag outright ("verbose + transactions are currently unsupported"), while get_history is a plain, + universally-supported method every server must implement. + """ + return await self.request("blockchain.scripthash.get_history", [scripthash]) + async def broadcast(self, raw_tx_hex: str) -> str: return await self.request("blockchain.transaction.broadcast", [raw_tx_hex]) diff --git a/app/electrum/listener.py b/app/electrum/listener.py index 42c7c43..dab78bc 100644 --- a/app/electrum/listener.py +++ b/app/electrum/listener.py @@ -6,9 +6,20 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker from app.db.models import User -from app.deposits.service import credit_confirmed_utxos, detect_external_spends +from app.deposits.service import ( + credit_confirmed_utxos, + find_utxos_missing_from, + mark_utxos_spent_externally, + reinstate_reappeared_utxos, +) from app.electrum.client import ElectrumClient, ElectrumEndpoint from app.electrum.scripthash import address_to_scripthash +from app.rounds.draw import ( + HeaderValidationError, + header_hex_to_block_hash, + header_meets_its_own_target, + header_prev_hash, +) from app.rounds.events import broadcaster logger = logging.getLogger(__name__) @@ -18,6 +29,17 @@ logger = logging.getLogger(__name__) # difference between noticing the drop in a minute and never noticing it at all. _PING_INTERVAL_SECONDS = 60 +# How long to wait for any *one* other server's answer when corroborating the +# draw's block header (B-28) or a candidate external spend (B-29). Shorter than +# the standard request timeout since this is a supplementary check across several +# servers at once — a single slow fallback shouldn't hold up the others. +_CORROBORATION_TIMEOUT_SECONDS = 10 + +# How many users to resubscribe at once on reconnect (B-31), instead of one at a +# time. Bounded rather than unlimited so a huge user base doesn't open thousands +# of simultaneous in-flight requests against the one active connection. +_RESUBSCRIBE_CONCURRENCY = 20 + class ElectrumListener: """Long-lived background task: keeps one Electrum connection open, subscribes @@ -46,6 +68,11 @@ class ElectrumListener: self._endpoints = list(endpoints or []) self._endpoint_index = 0 self._scripthash_to_user: dict[str, int] = {} + # Retains address_for_new_user's fire-and-forget subscribe task so it + # can't be garbage-collected mid-flight, and so its exception (if any) is + # actually observed instead of only reaching asyncio's default "Task + # exception was never retrieved" handler (B-30). + self._background_tasks: set[asyncio.Task] = set() self.tip_height: int = 0 self.tip_header_hex: str | None = None self.client: ElectrumClient | None = None @@ -60,11 +87,32 @@ class ElectrumListener: def address_for_new_user(self, user_id: int, address: str) -> None: """Called right after a user registers so their deposit address starts - being watched immediately, without waiting for the next reconnect cycle.""" + being watched immediately, without waiting for the next reconnect cycle. + + Best-effort, not retried on its own: `self.client` can still become None + between the check below and the task actually running (the connection + drops in between), which used to raise an AssertionError inside an + untracked task and vanish silently (B-30). The exception is now logged + instead, and — since a failure here just means this one address stays + unsubscribed until the next reconnect's `_subscribe_all_users` or the + periodic `DepositReconciler` sweep (also B-30) catches it — that's an + acceptable, self-healing outcome rather than something worth its own + retry/backoff loop. + """ scripthash = address_to_scripthash(address) self._scripthash_to_user[scripthash] = user_id if self.client is not None: - asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id)) + task = asyncio.create_task(self._subscribe_and_refresh(scripthash, user_id)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + task.add_done_callback(self._log_subscribe_task_failure) + + def _log_subscribe_task_failure(self, task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning("could not subscribe a newly-registered user's address: %r", exc) async def run(self) -> None: backoff = 1 @@ -118,8 +166,6 @@ class ElectrumListener: header = await client.subscribe_headers() self._apply_header(header) - await self._subscribe_all_users() - headers_queue = client.notifications("blockchain.headers.subscribe") scripthash_queue = client.notifications("blockchain.scripthash.subscribe") # The consumers below block on their queues forever by design, so they @@ -132,9 +178,25 @@ class ElectrumListener: asyncio.create_task(self._keepalive(client)), asyncio.create_task(client.wait_closed()), ] + # B-31: resubscribing every user is O(users) sequential round-trips — + # at thousands of users that's minutes during which, previously, + # nothing above had started yet: tip_height was frozen and an + # in-flight draw's _wait_for_next_block made zero progress for the + # entire resubscribe. Running it as its own background task instead + # of awaiting it inline here means tip updates (and notifications for + # whichever users are already subscribed) keep flowing throughout. + # It's deliberately not one of the raced `tasks` above: unlike those, + # it's expected to finish normally, and its own completion must not + # look like the session ending. Any failure partway through is + # logged the same way address_for_new_user's background task is + # (B-30), and it's cancelled below along with everything else once + # the session actually does end. + subscribe_task = asyncio.create_task(self._subscribe_all_users()) + subscribe_task.add_done_callback(self._log_subscribe_all_users_failure) try: done, still_running = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) finally: + subscribe_task.cancel() for task in tasks: task.cancel() for task in done: @@ -146,23 +208,45 @@ class ElectrumListener: await client.close() return True + def _log_subscribe_all_users_failure(self, task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.warning("resubscribing all users failed partway through: %r", exc) + async def _keepalive(self, client: ElectrumClient) -> None: while True: await asyncio.sleep(_PING_INTERVAL_SECONDS) await client.ping() # raises (and so ends the session) on timeout or a dead socket async def _subscribe_all_users(self) -> None: + """B-31: subscribes with bounded concurrency (_RESUBSCRIBE_CONCURRENCY at + a time) instead of one user at a time — at thousands of users a serial + loop meant thousands of sequential round-trips. One user's failure (a + single slow or briefly-erroring request) must not stop the rest from + being subscribed, mirroring the same per-item isolation used elsewhere + (e.g. tx/confirmation.py's poll_once, deposits/reconcile.py's sweep).""" async with self._session_factory() as session: users = (await session.scalars(select(User))).all() - for user in users: + + semaphore = asyncio.Semaphore(_RESUBSCRIBE_CONCURRENCY) + + async def _subscribe_one(user: User) -> None: scripthash = address_to_scripthash(user.address) self._scripthash_to_user[scripthash] = user.id - await self._subscribe_and_refresh(scripthash, user.id) + async with semaphore: + try: + await self._subscribe_and_refresh(scripthash, user.id) + except Exception: + logger.exception("failed to resubscribe user_id=%s", user.id) + + await asyncio.gather(*(_subscribe_one(user) for user in users)) async def _subscribe_and_refresh(self, scripthash: str, user_id: int) -> None: assert self.client is not None await self.client.subscribe_scripthash(scripthash) - await self._refresh_user(user_id, scripthash) + await self.refresh_user(user_id, scripthash) def _apply_header(self, header: dict) -> None: """Record a new chain tip, refusing to move backwards. @@ -173,8 +257,19 @@ class ElectrumListener: 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. + + 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. """ height = header["height"] + header_hex = header.get("hex") if height < self.tip_height: logger.warning( "ignoring Electrum header at height %s, below the current tip %s (reorg or server switch?)", @@ -182,8 +277,109 @@ class ElectrumListener: self.tip_height, ) 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})" + ) + self.tip_height = height - self.tip_header_hex = header.get("hex") + self.tip_header_hex = header_hex + + async def _corroborate_majority( + self, + ask: Callable[[ElectrumEndpoint], "asyncio.Future"], + 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 + 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. + """ + others = [endpoint for endpoint in self._endpoints if endpoint != self.current_endpoint] + if not others: + return True + + results = await asyncio.gather(*(ask(endpoint) for endpoint in others)) + responded = [result for result in results if result is not None] + if not responded: + logger.warning( + "could not corroborate %s with any of %s other configured server(s)", description, len(others) + ) + 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. + """ + + async def _ask(endpoint: ElectrumEndpoint) -> str | None: + 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) + 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") + + 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 + are treated as genuinely spent outside the platform, ask the other + configured servers whether *they* also no longer report this outpoint as + unspent. A single broken, behind, or malicious server could otherwise zero + a user's balance on one incomplete listunspent reply. See + _corroborate_majority for the shared quorum logic. + """ + + 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 + ) + still_unspent = any(e.get("tx_hash") == txid and e.get("tx_pos") == vout for e in entries) + return not still_unspent # True = this server agrees the outpoint is gone + except Exception: + return None + finally: + await client.close() + + return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}") async def _consume_headers(self, queue: asyncio.Queue) -> None: while True: @@ -200,15 +396,38 @@ class ElectrumListener: scripthash, _status = await queue.get() user_id = self._scripthash_to_user.get(scripthash) if user_id is not None: - await self._refresh_user(user_id, scripthash) + await self.refresh_user(user_id, scripthash) - async def _refresh_user(self, user_id: int, scripthash: str) -> None: + 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. + """ 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) - spent_externally = await detect_external_spends(session, user_id, entries) + reinstated = await reinstate_reappeared_utxos(session, user_id, entries) + candidates = [ + (row.id, row.txid, row.vout) + for row in await find_utxos_missing_from(session, user_id, entries) + ] + + confirmed_ids = [ + utxo_id + for utxo_id, txid, vout in candidates + if await self.corroborate_utxo_spent(scripthash, txid, vout) + ] + + spent_externally = 0 + if confirmed_ids: + async with self._session_factory() as session: + spent_externally = await mark_utxos_spent_externally(session, user_id, confirmed_ids) + if credited: logger.info("credited %s new UTXO(s) for user_id=%s", credited, user_id) + if reinstated: + logger.info("reinstated %s previously-flagged UTXO(s) for user_id=%s", reinstated, user_id) if spent_externally: logger.warning("%s UTXO(s) spent outside the platform for user_id=%s", spent_externally, user_id) diff --git a/app/main.py b/app/main.py index 03627e6..82c87bd 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ 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.db.base import AsyncSessionLocal +from app.deposits.reconcile import DepositReconciler from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints from app.electrum.listener import ElectrumListener from app.rounds.scheduler import RoundScheduler @@ -40,7 +41,7 @@ def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient: @asynccontextmanager async def lifespan(app: FastAPI): - # Refuses to serve rather than starting up half-configured — see B-15 in BUGS.md. + # Refuses to serve rather than starting up half-configured (B-15). validate_runtime_secrets() endpoints = parse_endpoints( @@ -61,6 +62,10 @@ async def lifespan(app: FastAPI): # 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) + # 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). + deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener) tasks = [ asyncio.create_task(listener.run()), @@ -68,6 +73,7 @@ async def lifespan(app: FastAPI): asyncio.create_task(poller.run()), asyncio.create_task(bumper.run()), asyncio.create_task(reconciler.run()), + asyncio.create_task(deposit_reconciler.run()), ] try: yield @@ -78,7 +84,16 @@ async def lifespan(app: FastAPI): await listener.client.close() -app = FastAPI(title="PLM Lottery", lifespan=lifespan) +# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin +# endpoints included, to anyone who requests them (B-42) — disabled unless +# ENABLE_API_DOCS is explicitly set, which should only happen in development. +app = FastAPI( + title="PLM Lottery", + lifespan=lifespan, + docs_url="/docs" if settings.enable_api_docs else None, + redoc_url="/redoc" if settings.enable_api_docs else None, + openapi_url="/openapi.json" if settings.enable_api_docs else None, +) app.include_router(auth_router) app.include_router(users_router) app.include_router(bets_router) diff --git a/app/rounds/draw.py b/app/rounds/draw.py index 9e26ffc..35107f8 100644 --- a/app/rounds/draw.py +++ b/app/rounds/draw.py @@ -1,5 +1,22 @@ import hashlib +# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) + +# merkle_root(32) + timestamp(4) + bits(4) + nonce(4). +_HEADER_LENGTH_BYTES = 80 +_PREV_BLOCK_OFFSET = 4 +_PREV_BLOCK_LENGTH = 32 +_BITS_OFFSET = 72 +_BITS_LENGTH = 4 + + +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 + previously accepted tip. Letting this propagate out of the header-consuming + task ends the current Electrum session the same way a dropped connection would + (see ElectrumListener._run_once), so the listener rotates to the next + configured server instead of trusting a header a server just forged.""" + def header_hex_to_block_hash(header_hex: str) -> str: """Block hash from a raw Electrum header: sha256d, byte-reversed, hex. @@ -10,6 +27,56 @@ def header_hex_to_block_hash(header_hex: str) -> str: return digest[::-1].hex() +def header_prev_hash(header_hex: str) -> str: + """The header's `prev_block` field, byte-reversed to the same conventional + (display) order as header_hex_to_block_hash's return value, so the two can be + compared directly to check that one header actually chains from another.""" + header_bytes = bytes.fromhex(header_hex) + prev = header_bytes[_PREV_BLOCK_OFFSET : _PREV_BLOCK_OFFSET + _PREV_BLOCK_LENGTH] + return prev[::-1].hex() + + +def _target_from_bits(bits: int) -> int: + """Decompress Bitcoin-style compact `nBits` difficulty encoding into the full + 256-bit target a valid header's hash must be less than or equal to.""" + exponent = bits >> 24 + mantissa = bits & 0xFFFFFF + if exponent <= 3: + return mantissa >> (8 * (3 - exponent)) + return mantissa << (8 * (exponent - 3)) + + +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 — + 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. + + This does NOT — and, short of downloading and validating the full header + chain's difficulty-retarget history, cannot — catch a header honestly mined at + 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) + rather than relying on this check alone. + """ + header_bytes = bytes.fromhex(header_hex) + if len(header_bytes) != _HEADER_LENGTH_BYTES: + return False + bits = int.from_bytes(header_bytes[_BITS_OFFSET : _BITS_OFFSET + _BITS_LENGTH], "little") + target = _target_from_bits(bits) + if target <= 0: + return False + digest = hashlib.sha256(hashlib.sha256(header_bytes).digest()).digest() + # The hash as the integer comparable against `target`: this is the same digest + # header_hex_to_block_hash reverses into the conventional display hex, so + # reading it byte-reversed as a big-endian int is equivalent to reading the + # original digest bytes as little-endian — both give the same integer. + hash_int = int.from_bytes(digest, "little") + 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 diff --git a/app/rounds/events.py b/app/rounds/events.py index d648e4a..c60cbab 100644 --- a/app/rounds/events.py +++ b/app/rounds/events.py @@ -1,16 +1,31 @@ import asyncio +from collections import defaultdict -# Defensive cap on concurrent SSE subscribers. Expected load is on the order of -# ~100 concurrent users; this is set well above that so it never engages under -# normal use — it exists purely so a runaway/DoS-y number of open connections -# degrades (new connections fall back to polling, see round_stream()) instead -# of growing the in-memory subscriber set without bound. Revisit this number if -# expected concurrency grows well past it. +# Defensive backstop on concurrent SSE subscribers overall, regardless of source +# — expected load is on the order of ~100 concurrent users, so this is set well +# above that. The real defense against a single abusive source is the per-IP cap +# below (B-38): a global-only cap was trivially exhausted by one client opening +# MAX_SUBSCRIBERS connections, degrading every other user to polling — the +# comment used to call it "defensive"; it was actually the vector. MAX_SUBSCRIBERS = 500 +# How many concurrent streams a single client IP may hold. Deliberately small — +# a real browser tab needs at most one, occasionally two briefly across a +# reload — since this bounds one source's share of the global capacity, not a +# legitimate per-user concurrency limit. +MAX_SUBSCRIBERS_PER_IP = 5 + +# Put on a to-be-evicted subscriber's queue (B-38) to wake its generator +# (app/api/routes/rounds.py:round_stream) promptly so it closes the connection +# instead of lingering, silently uncounted, until the client's own network +# timeout or the next keep-alive tick. +EVICTED = object() + class RoundEventCapacityError(Exception): - """Raised by subscribe() when MAX_SUBSCRIBERS is already reached.""" + """Raised by subscribe() when MAX_SUBSCRIBERS — the global backstop — is + already reached. The per-IP cap never raises this; it evicts instead (see + subscribe()).""" class RoundEventBroadcaster: @@ -26,22 +41,47 @@ class RoundEventBroadcaster: would need a shared channel (e.g. Redis pub/sub) instead. """ - def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS): - self._subscribers: set[asyncio.Queue] = set() + def __init__(self, max_subscribers: int = MAX_SUBSCRIBERS, max_per_ip: int = MAX_SUBSCRIBERS_PER_IP): + self._ip_by_queue: dict[asyncio.Queue, str] = {} + self._queues_by_ip: dict[str, list[asyncio.Queue]] = defaultdict(list) self.max_subscribers = max_subscribers + self.max_per_ip = max_per_ip - def subscribe(self) -> asyncio.Queue: - if len(self._subscribers) >= self.max_subscribers: + def subscribe(self, client_ip: str = "unknown") -> asyncio.Queue: + if len(self._ip_by_queue) >= self.max_subscribers: raise RoundEventCapacityError(f"already at the {self.max_subscribers}-subscriber cap") + + ip_queues = self._queues_by_ip[client_ip] + if len(ip_queues) >= self.max_per_ip: + # B-38: evict this IP's own oldest connection rather than refusing + # the new one — bounds one source's footprint without turning a + # legitimate reconnect storm (a flaky network retrying EventSource) + # into an outright block, and without letting one abusive IP crowd + # out unrelated clients the way the old global-only cap did. + oldest = ip_queues.pop(0) + self._ip_by_queue.pop(oldest, None) + if not oldest.full(): + oldest.put_nowait(EVICTED) + queue: asyncio.Queue = asyncio.Queue(maxsize=1) - self._subscribers.add(queue) + self._ip_by_queue[queue] = client_ip + ip_queues.append(queue) return queue def unsubscribe(self, queue: asyncio.Queue) -> None: - self._subscribers.discard(queue) + client_ip = self._ip_by_queue.pop(queue, None) + if client_ip is None: + return + ip_queues = self._queues_by_ip.get(client_ip) + if ip_queues is None: + return + if queue in ip_queues: + ip_queues.remove(queue) + if not ip_queues: + self._queues_by_ip.pop(client_ip, None) def publish(self) -> None: - for queue in self._subscribers: + for queue in self._ip_by_queue: if queue.full(): continue # a not-yet-delivered notification already covers this one queue.put_nowait(None) diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 614eec7..28f5647 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -3,11 +3,12 @@ import logging from datetime import datetime, timedelta, timezone from embit import script +from embit.transaction import Transaction from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.audit.log import write_audit_log -from app.db.models import PendingTransaction, Round, RoundParticipant, User +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 @@ -22,6 +23,20 @@ logger = logging.getLogger(__name__) _TICK_INTERVAL_SECONDS = 5 +# B-26: how long to wait after a payout failure before automatically retrying it. +# Long enough that a persistently-broken payout (misconfigured fee_address, +# insufficient pool UTXOs) doesn't re-attempt — and re-write a payout_failed audit +# entry — every _TICK_INTERVAL_SECONDS; short enough that a transient failure +# (a dropped Electrum connection, a momentarily-empty pool) self-heals quickly. +_PAYOUT_RETRY_INTERVAL_SECONDS = 60 + +# B-36: _wait_for_next_block has no timeout of its own — a round can legitimately +# wait several PLM blocks (120s each) for its draw entropy, and re-waits on a +# corroboration failure. These only make an already-long wait *observable*, they +# never cut it short. +_DRAW_PROGRESS_LOG_INTERVAL_SECONDS = 60 +_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 @@ -55,8 +70,17 @@ class RoundScheduler: round_id, status, opened_at = round_.id, round_.status, round_.opened_at round_duration_seconds = (await get_round_config(session)).round_duration_seconds + if status == "paying_out": + # B-26: _trigger_payout used to run exactly once, from _close_and_draw — + # any failure after that (no Electrum client, insufficient pool UTXOs, a + # rejected broadcast) or a process restart while paying_out left the round + # wedged here forever. Every tick now re-checks and retries, throttled by + # _retry_payout_if_due so a persistent failure doesn't retry on every tick. + await self._retry_payout_if_due(round_id) + return + if status not in ("open", "closing"): - return # already drawing/paying_out; progress happens elsewhere + return # "drawing" — progress happens inside the in-flight _close_and_draw call if status == "open": opened_at = opened_at.replace(tzinfo=timezone.utc) @@ -121,11 +145,13 @@ class RoundScheduler: user_by_address[user.address] = user.id round_.status = "drawing" + drawing_started_at = datetime.now(timezone.utc) + round_.drawing_started_at = drawing_started_at await session.commit() broadcaster.publish() tip_at_close = self._listener.tip_height - block_height, block_hash = await self._wait_for_next_block(tip_at_close) + 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) async with self._session_factory() as session: @@ -155,22 +181,143 @@ 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, tip_at_close: int) -> tuple[int, str]: - while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex: - await asyncio.sleep(_TICK_INTERVAL_SECONDS) - return self._listener.tip_height, header_hex_to_block_hash(self._listener.tip_header_hex) + async def _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. + + 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. + """ + 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) + 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)", + round_id, + tip_at_close, + (now - waiting_since).total_seconds(), + ) + 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()), + }, + 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, + ) + await session.commit() + tip_at_close = height + + async def _retry_payout_if_due(self, round_id: int) -> None: + """B-26: whether a "paying_out" round is due for another payout attempt. + + Throttled by the most recent payout_failed audit entry for this round + (written by _log_payout_failure on every early return in _trigger_payout, + including ones that used to fail silently) rather than by any new DB state, + since a failed attempt doesn't necessarily leave a PendingTransaction behind + (a build failure like a missing fee_address never gets that far). No entry + yet means this round hasn't failed before — either it's a fresh "paying_out" + (the very first call already happened from _close_and_draw and hasn't had a + chance to fail yet) or the process restarted before ever recording one — + either way it's due immediately. + """ + async with self._session_factory() as session: + last_failure_at = await session.scalar( + select(AuditLog.created_at) + .where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id) + .order_by(AuditLog.id.desc()) + .limit(1) + ) + if last_failure_at is not None: + last_failure_at = last_failure_at.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) < last_failure_at + timedelta(seconds=_PAYOUT_RETRY_INTERVAL_SECONDS): + return # too soon — avoid hammering a persistently-broken payout + await self._trigger_payout(round_id) async def _trigger_payout(self, round_id: int) -> None: - """Three phases, so no DB session is held across a network call (B-18): read - what's needed, do the chain work, then persist the outcome.""" + """Four phases, so no DB session is held across a network call (B-18): read + what's needed, build the tx, persist the intent, then broadcast. + + The persist happens *before* the broadcast (B-25) — the same two-phase shape + as place_bet/request_withdrawal (B-08): a crash between building the payout + and recording it used to leave money on-chain with zero trace in the DB (no + payout_txid, no PendingTransaction), so a manual retry would have paid the + winner a second time. Now the worst case is a "building" PendingTransaction + the reconciler (app/tx/reconcile.py) can resolve either way by asking the + chain whether the tx exists, exactly like it already does for bets and + withdrawals. + """ client = self._listener.client if client is None: logger.error("round %s payout deferred: not connected", round_id) + await self._log_payout_failure(round_id, None, "electrum client not connected") return # --- Phase 1: read (session closed before any network I/O) --------------- async with self._session_factory() as session: round_ = await session.get(Round, round_id) + already_in_flight = await session.scalar( + select(PendingTransaction).where( + PendingTransaction.round_id == round_id, + PendingTransaction.kind == "payout", + PendingTransaction.status.in_(("building", "pending")), + ) + ) + if already_in_flight is not None: + # A payout for this round is already building or broadcast — this + # must not build a second one, or a retry (manual, or a future + # automatic one) would pay the winner twice. Confirmation/ + # reconciliation already owns resolving that row. + logger.info( + "round %s payout already in flight (pending_transaction %s), skipping", + round_id, + already_in_flight.id, + ) + return + reserved_outpoints = await _reserved_payout_outpoints(session) config = await get_round_config(session) fee_address = config.fee_address fee_rate = config.fee_rate_sat_vb @@ -184,21 +331,27 @@ class RoundScheduler: logger.error( "round %s payout blocked: no fee_address configured (set it via the admin endpoint)", round_id ) + await self._log_payout_failure(round_id, winner_user_id, "no fee_address configured") return if winner_address is None: logger.error("round %s payout blocked: winner user %s not found", round_id, winner_user_id) + await self._log_payout_failure(round_id, winner_user_id, "winner user not found") return winner_share = pool_amount_sats * 70 // 100 commission_share = pool_amount_sats - winner_share # remainder from rounding goes to fees - # --- Phase 2: build and broadcast ---------------------------------------- + # --- Phase 2: build (network read only, no DB write yet) ----------------- try: pool_key = derive_pool_key() 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)) - utxos = [Utxo(e["tx_hash"], e["tx_pos"], e["value"]) for e in entries if e["height"] > 0] + utxos = [ + Utxo(e["tx_hash"], e["tx_pos"], e["value"]) + for e in entries + if e["height"] > 0 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints + ] built = build_payout_transaction( signing_key=pool_key, @@ -211,36 +364,58 @@ class RoundScheduler: change_address=pool_address, fee_rate_sat_vb=fee_rate, ) - await client.broadcast(built.raw_hex) - except InsufficientFundsError: - logger.exception("round %s payout failed: insufficient pool UTXOs", round_id) + 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. + 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) return except Exception: - # Anything else — a malformed fee_address (EmbitError), a rejected - # broadcast, a dead connection. This used to escape all the way to - # run()'s catch-all, which logged it without recording anything, leaving - # no trace of *why* the round was stuck (B-05). The round stays in - # "paying_out" either way: automatic payout retry is still an open gap. - logger.exception("round %s payout failed", round_id) - await self._log_payout_failure(round_id, winner_user_id) + # Anything else — a malformed fee_address (EmbitError) or similar. This + # used to escape all the way to run()'s catch-all, which logged it + # without recording anything, leaving no trace of *why* the round was + # stuck (B-05). _retry_payout_if_due (B-26) is what turns this recorded + # failure into an automatic retry instead of a dead end. + logger.exception("round %s payout build failed", round_id) + await self._log_payout_failure(round_id, winner_user_id, "payout build failed") return - # --- Phase 3: persist ----------------------------------------------------- + # --- Phase 3: persist the intent, *then* broadcast (B-25) ----------------- async with self._session_factory() as session: round_ = await session.get(Round, round_id) round_.winner_amount_sats = built.winner_sats round_.fee_amount_sats = built.commission_sats round_.payout_txid = built.txid - session.add( - PendingTransaction( - kind="payout", - round_id=round_id, - current_txid=built.txid, - fee_rate_sat_vb=fee_rate, - raw_tx_hex=built.raw_hex, - status="pending", - ) + pending = PendingTransaction( + kind="payout", + round_id=round_id, + current_txid=built.txid, + fee_rate_sat_vb=fee_rate, + raw_tx_hex=built.raw_hex, + # "building" until the broadcast succeeds, exactly like place_bet's + # two phases — see the reconciler, which gives this a short grace + # period before asking the chain whether it made it out after all. + status="building", ) + session.add(pending) + 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) + await self._log_payout_failure(round_id, winner_user_id, "broadcast rejected") + return + + async with self._session_factory() as session: + pending = await session.get(PendingTransaction, pending_id) + pending.status = "pending" await write_audit_log( session, "payout_sent", @@ -252,18 +427,44 @@ class RoundScheduler: logger.info("round %s payout broadcast: txid=%s", round_id, built.txid) - async def _log_payout_failure(self, round_id: int, winner_user_id: int | None) -> None: + async def _log_payout_failure(self, round_id: int, winner_user_id: int | None, reason: str) -> None: """Leaves an operator-visible trace in the audit log for a round stuck in - "paying_out" — the logs alone don't show up in /admin.""" + "paying_out" — the logs alone don't show up in /admin. Called from every + early return in _trigger_payout (B-26), not just the generic exception + branch as before, so _retry_payout_if_due always has an entry to throttle + against and /admin always shows *why* a round is stuck rather than just + that it is.""" try: async with self._session_factory() as session: await write_audit_log( session, "payout_failed", - {"round_id": round_id}, + {"round_id": round_id, "reason": reason}, user_id=winner_user_id, round_id=round_id, ) await session.commit() except Exception: logger.exception("could not record the payout failure of round %s", round_id) + + +async def _reserved_payout_outpoints(session: AsyncSession) -> set[tuple[str, int]]: + """Pool UTXOs already claimed by a payout that hasn't resolved yet — this + round's own in-flight payout (guarded against separately in _trigger_payout) or + a stale one from an earlier round the reconciler hasn't abandoned yet (B-25). + These must be excluded from selection, or a retry would double-spend the same + coins into two payouts before the reconciler gets a chance to release them.""" + rows = ( + await session.scalars( + select(PendingTransaction).where( + PendingTransaction.kind == "payout", + PendingTransaction.status.in_(("building", "pending")), + ) + ) + ).all() + reserved: set[tuple[str, int]] = set() + for row in rows: + tx = Transaction.parse(bytes.fromhex(row.raw_tx_hex)) + for vin in tx.vin: + reserved.add((vin.txid.hex(), vin.vout)) + return reserved diff --git a/app/static/app.js b/app/static/app.js index 3bba472..de2ee3a 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -749,10 +749,15 @@ async function changePassword() { await withLoading(btn, t('loading.updating'), async () => { try { - await call('POST', '/users/me/change-password', { + const data = await call('POST', '/users/me/change-password', { current_password: currentPassword, new_password: newPassword, }); + // The server just invalidated every previously issued token (B-34) — + // including the one this very request was authenticated with — and + // handed back a fresh one so this tab doesn't get logged out too. + token = data.access_token; + localStorage.setItem('plm_token', token); document.getElementById('settings-current-password').value = ''; document.getElementById('settings-new-password').value = ''; document.getElementById('settings-new-password-confirm').value = ''; diff --git a/app/static/i18n.js b/app/static/i18n.js index d83645f..38fa014 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -121,6 +121,7 @@ const TRANSLATIONS = { 'error.round_closing': 'The current round is closing, please try again shortly.', 'error.already_betting': 'You already have an active bet in the current round.', 'error.insufficient_balance': 'Insufficient balance.', + '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.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.', @@ -134,9 +135,11 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Enter an amount greater than zero.', 'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.', '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.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.', 'loading.creating': 'Creating…', 'loading.loggingIn': 'Logging in…', @@ -258,6 +261,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Il round corrente si sta chiudendo, riprova tra poco.', 'error.already_betting': 'Hai già una bet attiva nel round corrente.', 'error.insufficient_balance': 'Saldo insufficiente.', + '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.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.", @@ -271,9 +275,11 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Inserisci un importo maggiore di zero.', 'error.broadcast_failed': 'La rete ha rifiutato la transazione. 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.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.', 'loading.creating': 'Creazione…', 'loading.loggingIn': 'Accesso…', @@ -395,6 +401,7 @@ const TRANSLATIONS = { 'error.round_closing': 'La ronda actual se está cerrando, inténtalo de nuevo en un momento.', 'error.already_betting': 'Ya tienes una apuesta activa en la ronda actual.', 'error.insufficient_balance': 'Saldo insuficiente.', + '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.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.', @@ -408,9 +415,11 @@ const TRANSLATIONS = { '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.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.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.', 'loading.creating': 'Creando…', 'loading.loggingIn': 'Entrando…', @@ -532,6 +541,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Le round en cours est en train de se fermer, réessayez 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_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.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.', @@ -545,9 +555,11 @@ const TRANSLATIONS = { '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.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.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.', 'loading.creating': 'Création…', 'loading.loggingIn': 'Connexion…', @@ -669,6 +681,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Die laufende Runde wird gerade geschlossen, bitte versuche es gleich erneut.', 'error.already_betting': 'Du hast bereits eine aktive Wette in der laufenden Runde.', 'error.insufficient_balance': 'Nicht genügend Guthaben.', + '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.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.', @@ -682,9 +695,11 @@ const TRANSLATIONS = { '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.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.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.', 'loading.creating': 'Wird erstellt…', 'loading.loggingIn': 'Anmeldung…', @@ -806,6 +821,7 @@ const TRANSLATIONS = { 'error.round_closing': 'Текущий раунд закрывается, повторите попытку чуть позже.', 'error.already_betting': 'У вас уже есть активная ставка в текущем раунде.', 'error.insufficient_balance': 'Недостаточно средств.', + 'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.', 'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.', 'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).', 'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.', @@ -819,9 +835,11 @@ const TRANSLATIONS = { 'error.invalid_amount': 'Введите сумму больше нуля.', 'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.', 'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.', + 'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.', 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', 'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.', 'error.guide_unavailable': 'Руководство сейчас недоступно.', + 'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.', 'loading.creating': 'Создание…', 'loading.loggingIn': 'Вход…', @@ -943,6 +961,7 @@ const TRANSLATIONS = { 'error.round_closing': '当前回合正在结束,请稍后重试。', 'error.already_betting': '你在当前回合已有一笔有效下注。', 'error.insufficient_balance': '余额不足。', + 'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。', 'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。', 'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。', 'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。', @@ -956,9 +975,11 @@ const TRANSLATIONS = { 'error.invalid_amount': '请输入大于零的金额。', 'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。', 'error.amount_below_dust_limit': '金额过小,无法发送。', + 'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。', 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', 'error.internal_error': '服务器发生意外错误,请稍后重试。', 'error.guide_unavailable': '指南当前不可用。', + 'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。', 'loading.creating': '正在创建…', 'loading.loggingIn': '正在登录…', diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index 0df974e..4540a99 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -14,12 +14,18 @@ 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.wallet.plm_network import PLM_MAINNET -from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize +from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize logger = logging.getLogger(__name__) _POLL_INTERVAL_SECONDS = 30 -_FEE_RATE_INCREMENT = 1 # minimum relay-policy-friendly bump per BIP125 +_FEE_RATE_INCREMENT = 1 # how much pending.fee_rate_sat_vb's *target* rises by per bump + +# BIP125 rule 4: a replacement transaction must pay at least this much more, in +# total, per vbyte of its own size, than the transaction it replaces — Bitcoin +# Core's default incremental relay fee. bump_fee's delta must never fall below +# this regardless of what the target-rate arithmetic comes out to (B-32). +_INCREMENTAL_RELAY_FEE_RATE_SAT_VB = 1 class RbfError(Exception): @@ -27,22 +33,29 @@ class RbfError(Exception): def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool: - """Pure decision: has this pending tx been unconfirmed for longer than the - configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the - I/O-heavy bump_fee() so it's trivially unit-testable.""" + """Pure decision: has this pending tx gone unconfirmed for longer than the + configured timeout (RoundConfig.rbf_timeout_seconds) *since it was last + broadcast*? Kept separate from the I/O-heavy bump_fee() so it's trivially + unit-testable. + + Deliberately measured from last_broadcast_at, not broadcast_at: this decides + whether *another* bump is due, which should reset after every bump (a tx just + rebroadcast at a higher fee deserves the same grace period again) — unlike + reconcile.py's abandon check, which must measure from the *first* broadcast so + repeated bumping can't indefinitely postpone ever giving up on a tx (B-27).""" if pending.status != "pending": return False - return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) + return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) -async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple: +async def _signing_context(session: AsyncSession, kind: str, user_id: int | 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.""" - if pending.kind == "payout": + if kind == "payout": key = derive_pool_key() else: - user = await session.get(User, pending.user_id) + user = await session.get(User, user_id) key = derive_user_key(user.derivation_index) own_script = script.p2wpkh(key.to_public()) own_address = own_script.address(network=PLM_MAINNET) @@ -50,10 +63,19 @@ async def _signing_context(session: AsyncSession, pending: PendingTransaction) - async def _prevout_amount(client: ElectrumClient, vin: TransactionInput) -> int: + """The exact integer satoshi value of the output this input spends. + + Parsed directly from the raw transaction via embit rather than asking the + server for its own float, whole-coin-denominated "value" field (verbose=True) + and converting with `* 100_000_000` — embit's TransactionOutput.value is + already an integer number of satoshis straight from the tx's binary + encoding, so this never touches floating point in a codebase that is + otherwise strictly integer-satoshi (B-40). + """ txid_hex = vin.txid.hex() - tx = await client.get_transaction(txid_hex, verbose=True) - value_coins = tx["vout"][vin.vout]["value"] - return round(value_coins * 100_000_000) + raw_hex = await client.get_transaction(txid_hex, verbose=False) + prevout_tx = Transaction.parse(bytes.fromhex(raw_hex)) + return prevout_tx.vout[vin.vout].value def _find_change_output(tx: Transaction, change_address: str) -> int | None: @@ -63,33 +85,71 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None: return None -async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: PendingTransaction) -> str: - """Rebuild `pending`'s transaction with a higher fee (same inputs, same - recipient outputs, the extra fee taken from the change output) and - rebroadcast. Returns the new txid. +async def bump_fee( + session_factory: async_sessionmaker, client: ElectrumClient, pending_id: int +) -> 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). + + Three 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. 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. + 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. """ - old_tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex)) - signing_key, own_script, own_address = await _signing_context(session, pending) + # --- Phase 1: read what's needed, close the session before any network call --- + async with session_factory() as session: + pending = await session.get(PendingTransaction, pending_id) + if pending is None or pending.status != "pending": + logger.info("pending_transaction %s no longer pending; skipping bump", pending_id) + return None + if pending.fee_rate_sat_vb >= MAX_FEE_RATE_SAT_VB: + raise RbfError( + f"pending_transaction {pending_id}: already at the maximum fee rate " + f"({MAX_FEE_RATE_SAT_VB} sat/vB) — refusing to bump further" + ) + 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) + + # --- Phase 2: chain reads, signing, and the broadcast — no DB session open ---- + 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) old_fee = total_in - sum(o.value for o in old_tx.vout) + vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) - new_fee_rate = pending.fee_rate_sat_vb + _FEE_RATE_INCREMENT - new_fee = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) * new_fee_rate - fee_delta = new_fee - old_fee - if fee_delta <= 0: - fee_delta = _FEE_RATE_INCREMENT # already above the new target vsize*rate; bump by a token amount + target_fee_rate = min(current_fee_rate + _FEE_RATE_INCREMENT, MAX_FEE_RATE_SAT_VB) + target_fee = vsize * target_fee_rate + # BIP125 rule 4's minimum, in absolute sats for this tx's size — the floor + # `fee_delta` must never go below, no matter what `target_fee - old_fee` comes + # out to. That naive difference used to go to zero or negative whenever + # old_fee already exceeded target_fee (e.g. a dust change amount folded into + # the original fee — wallet/psbt_builder.py's DUST_LIMIT_SATS handling), and + # the previous fallback — a flat 1-satoshi total bump — was nowhere near this + # relay-mandated minimum, so the node rejected it every time. Because bump_fee + # raised before touching `pending`, the next tick retried with identical + # parameters every 30 seconds, forever (B-32). + min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB + fee_delta = max(target_fee - old_fee, min_valid_delta) change_index = _find_change_output(old_tx, own_address) if change_index is None or old_tx.vout[change_index].value <= fee_delta: - raise RbfError(f"pending_transaction {pending.id}: no change output large enough to absorb a fee bump") + raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump") new_vout = list(old_tx.vout) bumped_change = new_vout[change_index].value - fee_delta @@ -113,17 +173,28 @@ async def bump_fee(session: AsyncSession, client: ElectrumClient, pending: Pendi new_txid = final_tx.txid().hex() await client.broadcast(raw_hex) - 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 - pending.fee_rate_sat_vb = new_fee_rate - pending.attempt_count += 1 - pending.broadcast_at = datetime.now(timezone.utc) - await _retarget_txid_references(session, pending, old_txid, new_txid) - await session.commit() + # --- Phase 3: persist the outcome ---------------------------------------------- + async with session_factory() as session: + 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 + # 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) + await _retarget_txid_references(session, pending, old_txid, new_txid) + await session.commit() - logger.info("bumped %s pending_transaction %s: %s -> %s", pending.kind, pending.id, old_txid, new_txid) + logger.info("bumped %s pending_transaction %s: %s -> %s", kind, pending_id, old_txid, new_txid) return new_txid @@ -191,16 +262,12 @@ class RbfBumper: candidates = ( await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending")) ).all() - due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)] + due_ids = [p.id for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)] - for pending in due: - async with self._session_factory() as session: - row = await session.get(PendingTransaction, pending.id) - if row is None or row.status != "pending": - continue - try: - await bump_fee(session, client, row) - except RbfError: - logger.exception("could not bump pending_transaction %s", row.id) - except Exception: - logger.exception("unexpected error bumping pending_transaction %s", row.id) + for pending_id in due_ids: + try: + await bump_fee(self._session_factory, client, pending_id) + except RbfError: + logger.exception("could not bump pending_transaction %s", pending_id) + except Exception: + logger.exception("unexpected error bumping pending_transaction %s", pending_id) diff --git a/app/tx/confirmation.py b/app/tx/confirmation.py index a7fdb91..ab9a53a 100644 --- a/app/tx/confirmation.py +++ b/app/tx/confirmation.py @@ -7,7 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 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.pending_address import own_address_for logger = logging.getLogger(__name__) @@ -31,24 +33,52 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) candidates = ( await session.execute( select( - PendingTransaction.id, PendingTransaction.current_txid, PendingTransaction.kind + PendingTransaction.id, + PendingTransaction.current_txid, + PendingTransaction.kind, + PendingTransaction.user_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. + scripthash_by_id: dict[int, str] = {} + for pending_id, _txid, kind, user_id in candidates: + try: + address = await own_address_for(session, kind, user_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 - for pending_id, txid, kind in candidates: + history_cache: dict[str, list[dict]] = {} + for pending_id, txid, kind, _user_id in candidates: + scripthash = scripthash_by_id.get(pending_id) + if scripthash is None: + continue # address derivation failed above; already logged + try: - tx = await client.get_transaction(txid, verbose=True) + if scripthash not in history_cache: + history_cache[scripthash] = await client.get_history(scripthash) except Exception: - # One unresolvable txid must not stop the others: a tx the server no - # longer knows (dropped from the mempool, replaced) used to abort the - # whole pass, so nothing confirmed again until an operator intervened - # (B-03). Abandoning such a row is app/tx/reconcile.py's job, not ours. - logger.warning("could not check pending_transaction %s (txid %s)", pending_id, txid, exc_info=True) + # One unresolvable scripthash must not stop the others: a tx the server + # no longer knows about (dropped from the mempool, replaced) used to + # abort the whole pass via a verbose blockchain.transaction.get call + # that some servers reject outright (B-41), so nothing confirmed again + # until an operator intervened (B-03). Abandoning such a row is + # app/tx/reconcile.py's job, not ours. + logger.warning("could not fetch history for pending_transaction %s (txid %s)", pending_id, txid, exc_info=True) continue - if not tx or tx.get("confirmations", 0) < 1: + + entry = next((e for e in history_cache[scripthash] if e.get("tx_hash") == txid), None) + # 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: continue + async with session_factory() as session: row = await session.get(PendingTransaction, pending_id) if row is None or row.status != "pending": diff --git a/app/tx/pending_address.py b/app/tx/pending_address.py new file mode 100644 index 0000000..6cc2cff --- /dev/null +++ b/app/tx/pending_address.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import User +from app.wallet.hd import derive_pool_address, derive_user_address + + +async def own_address_for(session: AsyncSession, kind: str, user_id: int | 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. + 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). + + Shared by tx/confirmation.py and tx/reconcile.py (B-41): both now check + blockchain.scripthash.get_history for this address instead of asking + blockchain.transaction.get for a verbose reply, so the two can't derive + different addresses for the same row. + """ + if kind == "payout": + return derive_pool_address() + user = await session.get(User, user_id) + return derive_user_address(user.derivation_index) diff --git a/app/tx/reconcile.py b/app/tx/reconcile.py index 32ddf79..fcf4776 100644 --- a/app/tx/reconcile.py +++ b/app/tx/reconcile.py @@ -36,7 +36,9 @@ 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.electrum.client import ElectrumClient +from app.electrum.scripthash import address_to_scripthash from app.rounds.events import broadcaster +from app.tx.pending_address import own_address_for from app.wallet.balance import recompute_balance logger = logging.getLogger(__name__) @@ -72,22 +74,21 @@ class PendingTransactionReconciler: await asyncio.sleep(_POLL_INTERVAL_SECONDS) -async def _tx_exists_on_chain(client: ElectrumClient, txid: str) -> bool: - """True if the server knows this txid at all (mempool or mined). An error reply - means "unknown", which is the answer we're looking for; a transport failure is - *not* — that raises, and the caller leaves the row alone until next time.""" - try: - tx = await client.get_transaction(txid, verbose=True) - except Exception as exc: - message = str(exc).lower() - if "missing" in message or "not found" in message or "no such" in message or "unknown" in message: - return False - raise - return bool(tx) - - async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumClient) -> int: - """Returns how many rows were resolved (promoted or abandoned).""" + """Returns how many rows were resolved (promoted or abandoned). + + 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. + """ now = datetime.now(timezone.utc) async with session_factory() as session: rows = ( @@ -95,16 +96,25 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl select(PendingTransaction).where(PendingTransaction.status.in_(("building", "pending"))) ) ).all() - candidates = [ - (row.id, row.status, row.current_txid) - for row in rows - if _is_due(row, now) - ] + candidates = [] + for row in rows: + if not _is_due(row, now): + continue + try: + address = await own_address_for(session, row.kind, row.user_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)) resolved = 0 - for row_id, status, txid in candidates: + history_cache: dict[str, list[dict]] = {} + for row_id, status, txid, scripthash in candidates: try: - exists = await _tx_exists_on_chain(client, txid) + 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]) 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. @@ -129,6 +139,12 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl def _is_due(row: PendingTransaction, now: datetime) -> bool: + # 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. grace = _BUILDING_GRACE_SECONDS if row.status == "building" else _ABANDON_AFTER_SECONDS return now >= row.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=grace) diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 6c9ebf8..26f89f7 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -25,15 +25,38 @@ RBF_SEQUENCE = 0xFFFFFFFD # withdrawal fail at broadcast with an opaque error (B-06). DUST_LIMIT_SATS = 294 +# Sanity ceiling on any transaction's fee rate — shared by RoundConfig.fee_rate_sat_vb's +# admin-facing bound (app/api/routes/admin.py, so the two can't drift apart, the same +# reason MIN_PASSWORD_LENGTH is shared in auth/security.py) and tx/broadcast.py's RBF +# bump escalation, which refuses to bump a pending_transaction past this rate (B-32) — +# without a ceiling, a stuck transaction's fee climbed by 1 sat/vB every bump forever, +# 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. +MAX_TX_INPUTS = 50 + class InsufficientFundsError(Exception): """`code` is the machine-readable identifier the API layer forwards to the client so it can translate the failure (see app/api/errors.py); the message - itself stays English.""" + itself stays English, and `params` carries the values it interpolates so the + translation can place them wherever its own grammar needs them.""" - def __init__(self, message: str, code: str = "insufficient_balance") -> None: + def __init__( + self, + message: str, + code: str = "insufficient_balance", + **params: int | str, + ) -> None: super().__init__(message) self.code = code + self.params = params @dataclass @@ -60,11 +83,22 @@ def estimate_vsize(n_inputs: int, n_outputs: int) -> int: def select_utxos(utxos: list[Utxo], target_sats: int) -> tuple[list[Utxo], int]: """Greedily select UTXOs (largest first, to minimize input count) covering target_sats — the amount deducted from the sender's balance. The fee is paid - out of target_sats (see build_signed_transaction), not added on top of it.""" + 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 + 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) selected: list[Utxo] = [] total = 0 for utxo in ordered: + if len(selected) == MAX_TX_INPUTS: + raise InsufficientFundsError( + f"balance too fragmented: more than {MAX_TX_INPUTS} inputs would be needed", + code="too_many_inputs", + max_inputs=MAX_TX_INPUTS, + ) selected.append(utxo) total += utxo.amount_sats if total >= target_sats: diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index ff520d6..dc32af0 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -9,7 +9,7 @@ from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.wallet.address import is_valid_plm_address -from app.wallet.balance import recompute_balance +from app.wallet.balance import compute_pending_balance, recompute_balance from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import ( BuiltTransaction, @@ -56,7 +56,22 @@ async def request_withdrawal( select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None)) ) ).all() - if sum(u.amount_sats for u in unspent) < amount_sats: + confirmed_sats = sum(u.amount_sats for u in unspent) + if confirmed_sats < amount_sats: + # B-37: cached_balance_sats (== confirmed_sats here) can understate the real + # balance by a whole unconfirmed change output right after a bet/withdrawal — + # the UI shows pending_balance_sats instead (compute_pending_balance), which + # can cover an amount this check would otherwise reject as flatly + # "insufficient". Distinguish "you don't have the money" from "your money + # hasn't confirmed yet" so the error doesn't contradict what the user is + # looking at on screen. + pending_inclusive_sats, has_pending = await compute_pending_balance(session, user) + if has_pending and pending_inclusive_sats >= amount_sats: + raise WithdrawalError( + "balance_pending_confirmation", + "the requested amount is covered by your pending balance, which has not confirmed yet", + pending_sats=pending_inclusive_sats - confirmed_sats, + ) raise WithdrawalError("insufficient_balance", "insufficient balance", required_sats=amount_sats) user_key = derive_user_key(user.derivation_index) @@ -74,7 +89,7 @@ async def request_withdrawal( fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: - raise WithdrawalError(exc.code, str(exc)) from exc + raise WithdrawalError(exc.code, str(exc), **exc.params) from exc # Persist the intent before broadcasting, and only promote the rows once the # network has accepted the tx — same two-phase shape as place_bet (B-08). @@ -158,3 +173,4 @@ async def _release_failed_withdrawal( user_id=user_id, ) await session.commit() + broadcaster.publish() # the reserved UTXOs are spendable again — refetch the balance (B-49) diff --git a/docs/guida-admin.md b/docs/guida-admin.md index be07fc9..abcb212 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -121,8 +121,9 @@ Eventi a cui vale la pena prestare attenzione: ## Alternative all'interfaccia grafica Le stesse operazioni si possono fare da terminale o da Swagger UI -(`https:///docs`, sezione `admin`), sempre passando `ADMIN_TOKEN` -nell'header `X-Admin-Token`: +(`https:///docs`, sezione `admin` — disponibile solo se `ENABLE_API_DOCS=true` +è impostato in `.env`, disattivata di default perché espone l'intera API), +sempre passando `ADMIN_TOKEN` nell'header `X-Admin-Token`: ```bash # leggere la configurazione diff --git a/docs/running-the-server.md b/docs/running-the-server.md index ad396ed..ffd8b37 100644 --- a/docs/running-the-server.md +++ b/docs/running-the-server.md @@ -3,24 +3,14 @@ Presuppone che [setup.md](setup.md) sia già stato completato (`.env` pronto, master key generata, migrazioni applicate). -## Locale / venv (sviluppo rapido) +Il server gira sempre via Docker, in sviluppo e in produzione allo stesso +modo — non esiste un modo supportato per lanciare `uvicorn` direttamente. +Il venv locale (`.venv/`) serve solo per i test, per scrivere le migrazioni +Alembic e per gli script una tantum di generazione chiavi (vedi +[setup.md](setup.md) e la sezione "Commands" di +[CLAUDE.md](../CLAUDE.md#commands)). -```bash -source .venv/bin/activate -uvicorn app.main:app --reload --port 8123 -``` - -- App su `http://127.0.0.1:8123/` -- Pannello admin su `http://127.0.0.1:8123/admin` -- Log applicativi in `logs/app.log` (rotante, 10MB × 5 backup) -- Nessun TLS, nessun reverse proxy — solo per test locali sulla tua macchina. - -Per fermarlo: `Ctrl+C`, oppure se lanciato in background con `nohup`: -```bash -pkill -f "uvicorn app.main:app" -``` - -## Docker + Caddy (consigliato, anche per i test con dominio/TLS) +## Docker + Caddy (unico workflow supportato) ```bash mkdir -p data/db data/keys data/logs # una tantum, se non già presenti diff --git a/docs/setup.md b/docs/setup.md index 97338d9..373e972 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -38,6 +38,11 @@ cp .env.example .env Le altre chiavi di `.env` (`DATABASE_URL`, `ELECTRUM_HOST`/`PORT`/`USE_SSL`, `MASTER_KEY_PATH`) hanno default sensati in `.env.example`. +`ENABLE_API_DOCS` (default `false`) controlla Swagger/ReDoc/l'OpenAPI JSON grezzo +su `/docs`, `/redoc` e `/openapi.json`: espongono l'intera superficie dell'API, +endpoint admin inclusi, quindi restano disattivati a meno di non impostarlo +esplicitamente a `true` — utile in locale, da evitare in produzione. + `ELECTRUM_FALLBACK_SERVERS` elenca i server di riserva, separati da virgola, nel formato `host:porta` (TLS, il caso normale) oppure `host:porta:notls`. Esempio: diff --git a/migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py b/migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py new file mode 100644 index 0000000..d56abc8 --- /dev/null +++ b/migrations/versions/861e76aaf34c_add_last_broadcast_at_to_pending_.py @@ -0,0 +1,41 @@ +"""add last_broadcast_at to pending_transactions + +Fixes B-27: bump_fee used to overwrite broadcast_at on every RBF bump, but +tx/reconcile.py's abandon-after-N-hours grace period is measured from that same +column — so a transaction bumped repeatedly but never mined reset that clock on +every bump and was never abandoned. broadcast_at now stays the *first* broadcast +(what the reconciler measures from); last_broadcast_at is the new column bump_fee +updates and should_bump reads to decide whether another bump is due. + +Backfilled from the existing broadcast_at (the best available approximation for +rows written before this column existed — for a row never bumped it's exact) +before the NOT NULL constraint is applied, so this is safe against any existing +data. + +Revision ID: 861e76aaf34c +Revises: 8a1c4e7b2d90 +Create Date: 2026-07-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '861e76aaf34c' +down_revision: Union[str, Sequence[str], None] = '8a1c4e7b2d90' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('pending_transactions', sa.Column('last_broadcast_at', sa.DateTime(), nullable=True)) + op.execute('UPDATE pending_transactions SET last_broadcast_at = broadcast_at') + with op.batch_alter_table('pending_transactions') as batch_op: + batch_op.alter_column('last_broadcast_at', nullable=False) + + +def downgrade() -> None: + op.drop_column('pending_transactions', 'last_broadcast_at') diff --git a/migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py b/migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py new file mode 100644 index 0000000..c7200d7 --- /dev/null +++ b/migrations/versions/87a0c640355c_widen_raw_tx_hex_and_payload_json_to_.py @@ -0,0 +1,38 @@ +"""widen raw_tx_hex and payload_json to Text + +Fixes B-47: both columns held arbitrary-length data (a raw signed transaction +hex, and a JSON audit payload) in an unbounded `String`, which SQLAlchemy +compiles to `VARCHAR` with no length. That's accepted by SQLite and +PostgreSQL but rejected by other backends (e.g. MySQL requires a length on +VARCHAR) — `Text` is the portable type for both. + +Revision ID: 87a0c640355c +Revises: 9ef6a51509f7 +Create Date: 2026-07-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '87a0c640355c' +down_revision: Union[str, Sequence[str], None] = '9ef6a51509f7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('audit_log') as batch_op: + batch_op.alter_column('payload_json', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False) + with op.batch_alter_table('pending_transactions') as batch_op: + batch_op.alter_column('raw_tx_hex', existing_type=sa.VARCHAR(), type_=sa.Text(), existing_nullable=False) + + +def downgrade() -> None: + with op.batch_alter_table('pending_transactions') as batch_op: + batch_op.alter_column('raw_tx_hex', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False) + with op.batch_alter_table('audit_log') as batch_op: + batch_op.alter_column('payload_json', existing_type=sa.Text(), type_=sa.VARCHAR(), existing_nullable=False) diff --git a/migrations/versions/943dbd74d983_add_token_version_to_users.py b/migrations/versions/943dbd74d983_add_token_version_to_users.py new file mode 100644 index 0000000..7f9063c --- /dev/null +++ b/migrations/versions/943dbd74d983_add_token_version_to_users.py @@ -0,0 +1,40 @@ +"""add token_version to users + +Revision ID: 943dbd74d983 +Revises: 861e76aaf34c +Create Date: 2026-07-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '943dbd74d983' +down_revision: Union[str, Sequence[str], None] = '861e76aaf34c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # server_default backfills every existing user to 0 (their current sessions + # stay valid, since 0 also matches what already-issued tokens carry + # implicitly — see the "sub"-only tokens issued before this migration); + # dropped right after so new rows go through the ORM default instead of a + # stale constant. + op.add_column( + 'users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0') + ) + with op.batch_alter_table('users') as batch_op: + batch_op.alter_column('token_version', server_default=None) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'token_version') + # ### end Alembic commands ### diff --git a/migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py b/migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py new file mode 100644 index 0000000..3b6c0f6 --- /dev/null +++ b/migrations/versions/9ef6a51509f7_add_drawing_started_at_to_rounds.py @@ -0,0 +1,32 @@ +"""add drawing_started_at to rounds + +Revision ID: 9ef6a51509f7 +Revises: 943dbd74d983 +Create Date: 2026-07-27 12:31:09.907682 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9ef6a51509f7' +down_revision: Union[str, Sequence[str], None] = '943dbd74d983' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rounds', sa.Column('drawing_started_at', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rounds', 'drawing_started_at') + # ### end Alembic commands ### diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 6690027..e17c4a7 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -67,6 +67,15 @@ async def test_admin_rejects_wrong_token(client): assert resp.status_code == 403 +async def test_admin_rejects_non_ascii_token_with_403_not_500(client): + """B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which + used to bubble up as a 500 instead of the expected 403. httpx encodes str + header values as ASCII client-side, so the raw UTF-8 bytes are passed + directly to reproduce what a real non-ASCII header on the wire looks like.""" + resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")}) + assert resp.status_code == 403 + + async def test_admin_reads_and_updates_config(client): headers = {"X-Admin-Token": "test-admin-token"} @@ -205,6 +214,10 @@ async def test_admin_resets_user_password(client): assert refreshed.password_hash != old_hash assert verify_password(new_password, refreshed.password_hash) assert not verify_password("original-password", refreshed.password_hash) + # B-34: the reset must bump token_version so a session opened before + # the reset (e.g. an attacker who had the old password) is evicted + # immediately rather than staying valid until the JWT naturally expires. + assert refreshed.token_version == 1 async def test_admin_reset_password_requires_token(client): @@ -273,3 +286,90 @@ async def test_pause_cannot_be_toggled_through_the_config_endpoint(client): resp = await client.put("/admin/config", headers=headers, json={"paused": True}) assert resp.status_code in (200, 422) # ignored or refused, but never applied assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False + + +@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"]) +@pytest.mark.parametrize("bad_limit", [0, -1, 501]) +async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit): + """B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an + unvalidated limit could dump the entire table in one response.""" + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit}) + assert resp.status_code == 422 + + +async def test_admin_list_rounds_respects_limit(client): + from app.db import base as db_base + from app.db.models import Round + + async with db_base.AsyncSessionLocal() as session: + session.add_all([Round(status="closed") for _ in range(3)]) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +async def test_admin_audit_log_respects_limit(client): + from app.db import base as db_base + from app.audit.log import write_audit_log + + async with db_base.AsyncSessionLocal() as session: + for _ in range(3): + await write_audit_log(session, "test_event", {}) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +async def _make_pending_transaction(session, *, kind="bet", status="pending"): + from app.db.models import PendingTransaction + import secrets as _secrets + + tx = PendingTransaction( + kind=kind, + current_txid=_secrets.token_hex(32), + fee_rate_sat_vb=1, + raw_tx_hex="00", + status=status, + ) + session.add(tx) + return tx + + +async def test_admin_pending_transactions_respects_limit(client): + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + for _ in range(3): + await _make_pending_transaction(session) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2}) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +async def test_admin_pending_transactions_status_filter(client): + from app.db import base as db_base + + async with db_base.AsyncSessionLocal() as session: + await _make_pending_transaction(session, status="pending") + await _make_pending_transaction(session, status="confirmed") + await _make_pending_transaction(session, status="failed") + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get( + "/admin/pending-transactions", headers=headers, params={"status": "confirmed"} + ) + assert resp.status_code == 200 + entries = resp.json() + assert len(entries) == 1 + assert entries[0]["status"] == "confirmed" diff --git a/tests/unit/test_api_timeutil.py b/tests/unit/test_api_timeutil.py new file mode 100644 index 0000000..157ec90 --- /dev/null +++ b/tests/unit/test_api_timeutil.py @@ -0,0 +1,21 @@ +from datetime import datetime, timezone + +from app.api.timeutil import isoformat_utc + + +def test_naive_datetime_is_stamped_utc(): + # SQLite/aiosqlite round-trips DateTime columns as naive even though every + # value written is UTC (app.db.models.utcnow) — this is the exact shape + # returned by the ORM after a read (B-35). + naive = datetime(2026, 7, 27, 6, 56, 47, 489110) + result = isoformat_utc(naive) + assert result == "2026-07-27T06:56:47.489110+00:00" + + +def test_aware_datetime_is_left_unchanged(): + aware = datetime(2026, 7, 27, 6, 56, 47, tzinfo=timezone.utc) + assert isoformat_utc(aware) == aware.isoformat() + + +def test_none_passes_through(): + assert isoformat_utc(None) is None diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..e1d33e1 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,126 @@ +import pytest +from cryptography.fernet import Fernet +from httpx import ASGITransport, AsyncClient + +from app.config import settings + + +@pytest.fixture +async def client(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db") + monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret") + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + + import app.wallet.hd as hd + + hd._account_key = None + hd.generate_master_key() + + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app.db import base as db_base + + import app.db.models # noqa: F401 + + db_base.engine = create_async_engine(settings.database_url) + db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + + from app.db import session as db_session + + db_session.AsyncSessionLocal = db_base.AsyncSessionLocal + + async with db_base.engine.begin() as conn: + await conn.run_sync(db_base.Base.metadata.create_all) + + from fastapi import FastAPI + + from app.auth.routes import router as auth_router + from app.electrum.listener import ElectrumListener + + app = FastAPI() + app.include_router(auth_router) + app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + await db_base.engine.dispose() + + +async def _register(client, username="alice", password="original-password"): + resp = await client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 201 + return resp.json()["access_token"] + + +async def test_login_locks_out_after_repeated_failures(client): + await _register(client) + + for _ in range(5): + resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + assert resp.status_code == 401 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + assert resp.status_code == 429 + assert resp.json()["detail"]["code"] == "rate_limited" + + # Even the *correct* password is refused while locked out — the throttle + # protects against a lucky guess landing inside the backoff window too. + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 429 + + +async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client): + await _register(client, username="bob") + + for _ in range(5): + resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"}) + assert resp.status_code == 401 + assert resp.json()["detail"]["code"] == "invalid_credentials" + + resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"}) + assert resp.status_code == 429 + + +async def test_login_failures_against_one_account_do_not_lock_out_another(client): + await _register(client, username="alice") + await _register(client, username="carol", password="carols-password") + + for _ in range(6): + await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + + # Different username, but same IP (the test client always looks the same) — + # only the per-username bucket should be exhausted, not the whole IP, since + # the per-username threshold (5) is hit well before the shared IP bucket's. + resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"}) + assert resp.status_code == 200 + + +async def test_successful_login_resets_the_username_bucket(client): + await _register(client) + + for _ in range(4): + resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"}) + assert resp.status_code == 401 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 200 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 200 + + +async def test_registration_is_rate_limited_per_ip(client): + for i in range(5): + resp = await client.post( + "/auth/register", json={"username": f"user{i}", "password": "a-strong-password"} + ) + assert resp.status_code == 201 + + resp = await client.post( + "/auth/register", json={"username": "user5", "password": "a-strong-password"} + ) + assert resp.status_code == 429 + assert resp.json()["detail"]["code"] == "rate_limited" diff --git a/tests/unit/test_bets.py b/tests/unit/test_bets.py index aa2886a..a76efc2 100644 --- a/tests/unit/test_bets.py +++ b/tests/unit/test_bets.py @@ -8,8 +8,10 @@ from app.bets.service import BetError, place_bet from app.config import settings from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent +from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed from app.wallet.hd import derive_user_address +from app.wallet.psbt_builder import MAX_TX_INPUTS class FakeElectrumClient: @@ -91,6 +93,35 @@ async def test_place_bet_rejects_insufficient_balance(session_factory): await place_bet(session, client, user) +async def test_place_bet_reports_a_too_fragmented_balance_distinctly(session_factory): # B-48 + # 100 x 0.15 PLM = 15 PLM, plenty for a 10 PLM bet, but the 50 largest inputs + # only add up to 7.5 PLM — so the build must fail with its own code, not with + # the "you have no funds" one, and must carry the cap for the translation. + user_id = await _make_funded_user(session_factory, 20, 15_000_000) + async with session_factory() as session: + for i in range(99): + session.add( + UtxoEvent( + user_id=user_id, + txid=f"{i:064x}", + vout=0, + amount_sats=15_000_000, + confirmed_height=100, + ) + ) + await session.commit() + client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(BetError) as excinfo: + await place_bet(session, client, user) + + assert excinfo.value.code == "too_many_inputs" + assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS} + assert not client.broadcasted + + async def test_place_bet_rejects_second_bet_same_round(session_factory): user_id = await _make_funded_user(session_factory, 2, 3_000_000_000) client = FakeElectrumClient() @@ -166,6 +197,33 @@ async def test_failed_broadcast_leaves_nothing_behind(session_factory): assert "bet_placed" not in events +async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49 + """The rollback moves as much state as the successful path does, so it must ping + the dashboards the same way — otherwise the phantom bet stays on screen until the + next poll.""" + user_id = await _make_funded_user(session_factory, 21, 3_000_000_000) + async with session_factory() as session: + # Open the round up front: place_bet would otherwise open it itself, and that + # publish() would satisfy the assertion below whether or not the rollback ever + # published one of its own. + await open_new_round_if_needed(session) + await session.commit() + + queue = broadcaster.subscribe() + try: + while not queue.empty(): + queue.get_nowait() + + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(BetError, match="refused"): + await place_bet(session, RejectingElectrumClient(), user) + + assert not queue.empty() + finally: + broadcaster.unsubscribe(queue) + + async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory): user_id = await _make_funded_user(session_factory, 5, 3_000_000_000) diff --git a/tests/unit/test_broadcast.py b/tests/unit/test_broadcast.py index 3ccc33e..73bc59f 100644 --- a/tests/unit/test_broadcast.py +++ b/tests/unit/test_broadcast.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone import pytest from embit import script from embit.bip32 import HDKey -from embit.transaction import Transaction +from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.config import settings @@ -11,7 +11,7 @@ from app.db.base import Base from app.db.models import PendingTransaction, User from app.tx.broadcast import RbfError, bump_fee, should_bump from app.wallet.plm_network import PLM_MAINNET -from app.wallet.psbt_builder import Utxo, build_signed_transaction +from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, Utxo, build_signed_transaction, estimate_vsize def _key(seed_byte: int) -> HDKey: @@ -22,7 +22,7 @@ def _key(seed_byte: int) -> HDKey: def test_should_bump_false_before_timeout(): pending = PendingTransaction( kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending", - broadcast_at=datetime.now(timezone.utc), + broadcast_at=datetime.now(timezone.utc), last_broadcast_at=datetime.now(timezone.utc), ) assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False @@ -31,6 +31,7 @@ def test_should_bump_true_after_timeout(): pending = PendingTransaction( kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending", broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), ) assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is True @@ -39,17 +40,41 @@ def test_should_bump_false_when_not_pending(): pending = PendingTransaction( kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="confirmed", broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False + + +def test_should_bump_measures_from_last_broadcast_not_first(monkeypatch): + """B-27 regression: a tx first broadcast long ago, but bumped recently, must not + be due for another bump yet — should_bump has to look at last_broadcast_at, not + the original broadcast_at, or every tick would try to re-bump it.""" + pending = PendingTransaction( + kind="bet", current_txid="x", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=10_000), + last_broadcast_at=datetime.now(timezone.utc), ) assert should_bump(pending, datetime.now(timezone.utc), timeout_seconds=900) is False class FakeClient: + """B-40: _prevout_amount now asks for the raw (non-verbose) transaction and + reads its output value as an integer via embit, rather than a verbose reply's + float "value" field — so this fake must hand back a real, parseable raw tx + whose vout[0] carries the requested amount (every test here spends vout 0 of + its fixture UTXO).""" + def __init__(self, prevout_values: dict[str, int]): self._prevout_values = prevout_values self.broadcasted: list[str] = [] - async def get_transaction(self, txid: str, verbose: bool = False) -> dict: - return {"vout": {0: {"value": self._prevout_values[txid] / 100_000_000}}} + async def get_transaction(self, txid: str, verbose: bool = False) -> str: + assert verbose is False + fake_prevout_tx = Transaction( + vin=[TransactionInput(b"\x00" * 32, 0)], + vout=[TransactionOutput(self._prevout_values[txid], script.Script(b"\x00\x14" + b"\x00" * 20))], + ) + return fake_prevout_tx.serialize().hex() async def broadcast(self, raw_tx_hex: str) -> str: self.broadcasted.append(raw_tx_hex) @@ -116,9 +141,7 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory): client = FakeClient({utxo_txid: utxo_amount}) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - new_txid = await bump_fee(session, client, row) + new_txid = await bump_fee(session_factory, client, pending_id) assert client.broadcasted assert new_txid != built.txid @@ -136,6 +159,61 @@ async def test_bump_fee_shrinks_change_and_rebroadcasts(session_factory): assert row.attempt_count == 2 +async def test_bump_fee_leaves_broadcast_at_untouched(session_factory): + """B-27 regression: bump_fee must only ever update last_broadcast_at. Before + this, it overwrote broadcast_at on every bump — the same field + tx/reconcile.py's abandon-after-N-hours grace period measures from — so a + repeatedly-bumped-but-never-mined tx reset that clock forever and was never + abandoned.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(97).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "33" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + original_broadcast_at = datetime.now(timezone.utc) - timedelta(days=1) + async with session_factory() as session: + user = User(username="carol", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=1, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=original_broadcast_at, + last_broadcast_at=original_broadcast_at, + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + client = FakeClient({utxo_txid: utxo_amount}) + before_bump = datetime.now(timezone.utc) + + await bump_fee(session_factory, client, pending_id) + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + assert row.broadcast_at.replace(tzinfo=timezone.utc) == original_broadcast_at + assert row.last_broadcast_at.replace(tzinfo=timezone.utc) >= before_bump + + async def test_bump_fee_raises_when_no_change_output(session_factory): from app.wallet.hd import derive_user_address, derive_user_key @@ -175,10 +253,8 @@ async def test_bump_fee_raises_when_no_change_output(session_factory): client = FakeClient({utxo_txid: utxo_amount}) - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - with pytest.raises(RbfError): - await bump_fee(session, client, row) + with pytest.raises(RbfError): + await bump_fee(session_factory, client, pending_id) async def test_bump_fee_retargets_every_stored_txid(session_factory): @@ -232,9 +308,7 @@ async def test_bump_fee_retargets_every_stored_txid(session_factory): await session.commit() pending_id = pending.id - async with session_factory() as session: - row = await session.get(PendingTransaction, pending_id) - new_txid = await bump_fee(session, FakeClient({utxo_txid: utxo_amount}), row) + new_txid = await bump_fee(session_factory, FakeClient({utxo_txid: utxo_amount}), pending_id) async with session_factory() as session: from sqlalchemy import select @@ -248,3 +322,245 @@ async def test_bump_fee_retargets_every_stored_txid(session_factory): utxo = (await session.scalars(select(UtxoEvent))).one() assert utxo.spent_txid == new_txid + + +# --- B-32: the bump delta must always meet BIP125's relay-mandated minimum, and +# escalation must stop at a ceiling instead of retrying forever. ------------------ + + +async def test_bump_fee_meets_bip125_minimum_when_old_fee_already_exceeds_target(session_factory): + """old_fee (as bump_fee computes it from the actual prevout amounts) can end + up higher than vsize * target_fee_rate — e.g. because dust change was folded + into the original fee (wallet/psbt_builder.py's DUST_LIMIT_SATS handling). + The naive `target_fee - old_fee` goes negative in that case; the previous + fallback was a flat 1-satoshi total bump, nowhere near BIP125 rule 4's + required minimum, so the node rejected it every time and — since bump_fee + raised before touching `pending` — the next tick retried identically every + 30 seconds, forever. Simulated here by reporting a prevout inflated beyond + what was actually spent, which has the same effect on old_fee as dust + absorption would.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(96).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "55" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + async with session_factory() as session: + user = User(username="dave", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=1, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + # Reports a prevout inflated well beyond what was actually spent — has the + # same effect on old_fee as dust absorption would have: old_fee ends up far + # above vsize * target_fee_rate (target_fee_rate = 2 here). + inflated_excess = 50_000 + client = FakeClient({utxo_txid: utxo_amount + inflated_excess}) + + new_txid = await bump_fee(session_factory, client, pending_id) + + assert client.broadcasted + new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) + old_tx = Transaction.parse(bytes.fromhex(built.raw_hex)) + old_change = next(o.value for o in old_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address) + new_change = next(o.value for o in new_tx.vout if o.script_pubkey.address(network=PLM_MAINNET) == my_address) + + vsize = estimate_vsize(len(old_tx.vin), len(old_tx.vout)) + min_valid_delta = vsize * 1 # BIP125 rule 4's floor at a 1 sat/vB incremental relay fee + assert min_valid_delta > 1 # meaningfully more than the old flat "1 satoshi" fallback + assert old_change - new_change == min_valid_delta + + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + old_fee_as_bump_fee_computed_it = built.fee_sats + inflated_excess + expected_rate = (old_fee_as_bump_fee_computed_it + min_valid_delta) // vsize + assert row.fee_rate_sat_vb == expected_rate + assert row.fee_rate_sat_vb > 2 # the actual rate, not the naive (and too-low) target + + +async def test_bump_fee_refuses_once_at_the_max_fee_rate(session_factory): + """Without a ceiling, a stuck transaction's fee rate climbed by 1 sat/vB every + 30 seconds forever, eating further and further into the user's change.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(95).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "66" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + async with session_factory() as session: + user = User(username="erin", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=MAX_FEE_RATE_SAT_VB, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + client = FakeClient({utxo_txid: utxo_amount}) + + with pytest.raises(RbfError): + await bump_fee(session_factory, client, pending_id) + + assert not client.broadcasted + + +# --- B-40: bump_fee must not hold a DB session open across its network calls, +# and a row that's no longer pending by the time it runs is a quiet no-op. ------- + + +async def test_bump_fee_holds_no_session_open_during_network_calls(session_factory): + """The get_transaction-per-input reads and the broadcast must happen with no + DB session held open — the same shape used elsewhere for this reason (B-18, + electrum/listener.py's refresh_user for B-31) — otherwise a session sits + idle in the pool for the whole duration of what can be several slow network + round-trips.""" + from app.wallet.hd import derive_user_address, derive_user_key + + signer = derive_user_key(0) + my_address = derive_user_address(0) + from_script = script.p2wpkh(signer.to_public()) + to_address = script.p2wpkh(_key(94).to_public()).address(network=PLM_MAINNET) + + utxo_amount = 150_000_000 + utxo_txid = "77" * 32 + built = build_signed_transaction( + signing_key=signer, + from_script=from_script, + utxos=[Utxo(utxo_txid, 0, utxo_amount)], + to_address=to_address, + amount_sats=10_000_000, + change_address=my_address, + fee_rate_sat_vb=1, + ) + + async with session_factory() as session: + user = User(username="frank", password_hash="x", derivation_index=0, address=my_address) + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid=built.txid, + fee_rate_sat_vb=1, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + open_count = {"n": 0} + + class _TrackedSession: + def __init__(self, inner): + self._inner = inner + + async def __aenter__(self): + result = await self._inner.__aenter__() + open_count["n"] += 1 + return result + + async def __aexit__(self, *exc): + open_count["n"] -= 1 + return await self._inner.__aexit__(*exc) + + def tracking_session_factory(): + return _TrackedSession(session_factory()) + + class TrackingClient(FakeClient): + async def get_transaction(self, txid, verbose=False): + assert open_count["n"] == 0, "a session was held open during a network call" + return await super().get_transaction(txid, verbose) + + async def broadcast(self, raw_tx_hex): + assert open_count["n"] == 0, "a session was held open during the broadcast" + return await super().broadcast(raw_tx_hex) + + client = TrackingClient({utxo_txid: utxo_amount}) + await bump_fee(tracking_session_factory, client, pending_id) + + assert client.broadcasted + assert open_count["n"] == 0 # nothing left open afterwards either + + +async def test_bump_fee_is_a_noop_when_no_longer_pending(session_factory): + """A row can legitimately confirm (or otherwise leave "pending") between + being read as due and RbfBumper actually attempting the bump — a normal + race, not an error. Must return quietly rather than raising or touching + the network.""" + async with session_factory() as session: + user = User(username="grace", password_hash="x", derivation_index=0, address="plm1qxxx") + session.add(user) + await session.commit() + pending = PendingTransaction( + kind="bet", + user_id=user.id, + current_txid="already-confirmed-txid", + fee_rate_sat_vb=1, + raw_tx_hex="00", + status="confirmed", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + + client = FakeClient({}) + + result = await bump_fee(session_factory, client, pending_id) + + assert result is None + assert not client.broadcasted + + +async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory): + client = FakeClient({}) + result = await bump_fee(session_factory, client, 999_999) + assert result is None + assert not client.broadcasted diff --git a/tests/unit/test_caddyfile_security_headers.py b/tests/unit/test_caddyfile_security_headers.py new file mode 100644 index 0000000..ed12a9a --- /dev/null +++ b/tests/unit/test_caddyfile_security_headers.py @@ -0,0 +1,35 @@ +"""B-43: the Caddyfile must keep sending baseline security headers. Caddy adds +none of these on its own, and the JWT lives in localStorage, so a regression +here silently reopens an XSS/clickjacking exposure with no test ever failing +in the Python suite (the Caddyfile isn't imported/exercised by anything else).""" + +from pathlib import Path + +CADDYFILE = (Path(__file__).parent.parent.parent / "Caddyfile").read_text() + + +def test_header_block_present(): + assert "header {" in CADDYFILE + + +def test_hsts_is_set(): + assert "Strict-Transport-Security" in CADDYFILE + assert "max-age=" in CADDYFILE + + +def test_nosniff_is_set(): + assert 'X-Content-Type-Options "nosniff"' in CADDYFILE + + +def test_frame_ancestors_are_blocked(): + assert 'X-Frame-Options "DENY"' in CADDYFILE + assert "frame-ancestors 'none'" in CADDYFILE + + +def test_referrer_policy_is_set(): + assert "Referrer-Policy" in CADDYFILE + + +def test_csp_default_src_is_self(): + assert "Content-Security-Policy" in CADDYFILE + assert "default-src 'self'" in CADDYFILE diff --git a/tests/unit/test_client_ip.py b/tests/unit/test_client_ip.py new file mode 100644 index 0000000..acabfa4 --- /dev/null +++ b/tests/unit/test_client_ip.py @@ -0,0 +1,43 @@ +"""app.api.client_ip is shared by the login/registration throttles (B-33) and +the SSE per-IP subscriber cap (B-38) — both depend on it correctly preferring +X-Forwarded-For (Caddy reverse-proxies every request, see Caddyfile) over +request.client.host, which would otherwise be the proxy's own address.""" + +from starlette.requests import Request + +from app.api.client_ip import client_ip + + +def _request(*, forwarded: str | None = None, client_host: str | None = "127.0.0.1") -> Request: + headers = [(b"x-forwarded-for", forwarded.encode())] if forwarded else [] + scope = { + "type": "http", + "headers": headers, + "client": (client_host, 12345) if client_host else None, + } + return Request(scope) + + +def test_client_ip_prefers_x_forwarded_for(): + request = _request(forwarded="5.6.7.8", client_host="10.0.0.1") + assert client_ip(request) == "5.6.7.8" + + +def test_client_ip_takes_the_first_hop_of_a_forwarded_chain(): + request = _request(forwarded="5.6.7.8, 10.0.0.1, 172.17.0.1") + assert client_ip(request) == "5.6.7.8" + + +def test_client_ip_strips_whitespace(): + request = _request(forwarded=" 5.6.7.8 , 10.0.0.1") + assert client_ip(request) == "5.6.7.8" + + +def test_client_ip_falls_back_to_request_client_without_the_header(): + request = _request(forwarded=None, client_host="10.0.0.1") + assert client_ip(request) == "10.0.0.1" + + +def test_client_ip_falls_back_to_unknown_with_neither(): + request = _request(forwarded=None, client_host=None) + assert client_ip(request) == "unknown" diff --git a/tests/unit/test_confirmation.py b/tests/unit/test_confirmation.py index 056f3d4..2e9129e 100644 --- a/tests/unit/test_confirmation.py +++ b/tests/unit/test_confirmation.py @@ -1,41 +1,83 @@ import pytest -from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine import app.bets.confirmation # noqa: F401 (registers the "bet" handler) import app.rounds.confirmation # noqa: F401 (registers the "payout" handler) +from sqlalchemy import select + +from app.config import settings from app.db.base import Base -from app.db.models import PendingTransaction, Round, RoundParticipant +from app.db.models import PendingTransaction, Round, RoundParticipant, User +from app.electrum.scripthash import address_to_scripthash from app.tx.confirmation import poll_once +from app.wallet.hd import derive_user_address class FakeClient: - def __init__(self, confirmations_by_txid: dict[str, int]): - self._confirmations = confirmations_by_txid + """B-41: poll_once now asks blockchain.scripthash.get_history rather than a + verbose blockchain.transaction.get, so this hands back a flat history — + height > 0 means confirmed at that height, 0 (or absent) means still in the + mempool. The scripthash argument is ignored: every candidate's derived + address is looked up against the same known universe of txids, which is + fine since matching happens on tx_hash, not on which address asked.""" - async def get_transaction(self, txid: str, verbose: bool = False) -> dict: - return {"confirmations": self._confirmations.get(txid, 0)} + def __init__(self, heights_by_txid: dict[str, int]): + self._heights = heights_by_txid + + async def get_history(self, scripthash: str) -> list[dict]: + return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()] @pytest.fixture -async def session_factory(): +async def session_factory(tmp_path, monkeypatch): + # own_address_for (B-41) derives each row's address via the HD wallet, so + # poll_once now needs a real master key — same bootstrap test_broadcast.py + # and test_reconcile.py use. + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() + hd._account_key = None + + +async def _make_user(session, derivation_index: int) -> User: + user = User( + username=f"user{derivation_index}", + password_hash="x", + derivation_index=derivation_index, + address=derive_user_address(derivation_index), + ) + session.add(user) + await session.flush() + return user async def test_bet_confirmation_marks_participant_confirmed(session_factory): async with session_factory() as session: + user = await _make_user(session, 0) session.add(Round(id=1, status="open")) session.add( RoundParticipant( - round_id=1, user_id=1, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast" + round_id=1, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx1", status="broadcast" ) ) session.add( - PendingTransaction(kind="bet", round_id=1, user_id=1, current_txid="tx1", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending") + PendingTransaction( + kind="bet", round_id=1, user_id=user.id, current_txid="tx1", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) ) await session.commit() @@ -53,9 +95,17 @@ async def test_bet_confirmation_marks_participant_confirmed(session_factory): async def test_unconfirmed_tx_is_left_pending(session_factory): async with session_factory() as session: + user = await _make_user(session, 0) session.add(Round(id=2, status="open")) - session.add(RoundParticipant(round_id=2, user_id=1, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast")) - session.add(PendingTransaction(kind="bet", round_id=2, user_id=1, current_txid="tx2", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")) + session.add( + RoundParticipant(round_id=2, user_id=user.id, bet_amount_sats=1_000, bet_txid="tx2", status="broadcast") + ) + session.add( + PendingTransaction( + kind="bet", round_id=2, user_id=user.id, current_txid="tx2", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) await session.commit() client = FakeClient({"tx2": 0}) @@ -70,7 +120,11 @@ async def test_unconfirmed_tx_is_left_pending(session_factory): async def test_payout_confirmation_closes_round(session_factory): async with session_factory() as session: session.add(Round(id=3, status="paying_out", payout_txid="tx3")) - session.add(PendingTransaction(kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending")) + session.add( + PendingTransaction( + kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending" + ) + ) await session.commit() client = FakeClient({"tx3": 2}) @@ -83,43 +137,52 @@ async def test_payout_confirmation_closes_round(session_factory): class ExplodingClient: - """Answers for one txid and raises for the other — a tx the server no longer - knows (dropped from the mempool, replaced by a bump).""" + """Answers for one address's history and raises for the other's — the + get_history equivalent of a server that no longer knows a particular tx + (dropped from the mempool, replaced by a bump).""" - def __init__(self, known: dict[str, int], exploding_txid: str): - self._known = known - self._exploding = exploding_txid + def __init__(self, heights_by_txid: dict[str, int], exploding_scripthash: str): + self._heights = heights_by_txid + self._exploding = exploding_scripthash - async def get_transaction(self, txid: str, verbose: bool = False) -> dict: - if txid == self._exploding: - raise RuntimeError("missing transaction") - return {"confirmations": self._known.get(txid, 0)} + async def get_history(self, scripthash: str) -> list[dict]: + if scripthash == self._exploding: + raise RuntimeError("server error") + return [{"tx_hash": txid, "height": height} for txid, height in self._heights.items()] -async def test_one_unresolvable_txid_does_not_block_the_others(session_factory): - """B-03: the lookup used to be unguarded, so a single unknown txid aborted the - whole pass — nothing confirmed again until an operator intervened, which in turn - meant no round could ever close.""" +async def test_one_unresolvable_candidate_does_not_block_the_others(session_factory): + """B-03: the lookup used to be unguarded, so a single failing candidate aborted + the whole pass — nothing confirmed again until an operator intervened, which in + turn meant no round could ever close. B-41 changed the failure unit from "one + txid" to "one address's history", but the isolation guarantee is the same.""" async with session_factory() as session: + good_user = await _make_user(session, 0) + gone_user = await _make_user(session, 1) session.add(Round(id=10, status="open")) session.add( - RoundParticipant(round_id=10, user_id=1, bet_amount_sats=1_000, bet_txid="good", status="broadcast") - ) - session.add( - PendingTransaction( - kind="bet", round_id=10, user_id=2, current_txid="gone", fee_rate_sat_vb=1, raw_tx_hex="00", - status="pending", + RoundParticipant( + round_id=10, user_id=good_user.id, bet_amount_sats=1_000, bet_txid="good", status="broadcast" ) ) session.add( PendingTransaction( - kind="bet", round_id=10, user_id=1, current_txid="good", fee_rate_sat_vb=1, raw_tx_hex="00", - status="pending", + kind="bet", round_id=10, user_id=gone_user.id, current_txid="gone", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) + session.add( + PendingTransaction( + kind="bet", round_id=10, user_id=good_user.id, current_txid="good", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", ) ) await session.commit() - confirmed = await poll_once(session_factory, ExplodingClient({"good": 1}, exploding_txid="gone")) + exploding_scripthash = address_to_scripthash(derive_user_address(1)) + confirmed = await poll_once( + session_factory, ExplodingClient({"good": 1}, exploding_scripthash=exploding_scripthash) + ) assert confirmed == 1 # the healthy one still got processed async with session_factory() as session: @@ -135,15 +198,16 @@ async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory): a txid no participant carried — the participant stayed "broadcast" forever and the round could never close. It now resolves by (round_id, user_id).""" async with session_factory() as session: + user = await _make_user(session, 0) session.add(Round(id=11, status="open")) session.add( RoundParticipant( - round_id=11, user_id=7, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast" + round_id=11, user_id=user.id, bet_amount_sats=1_000, bet_txid="old-txid", status="broadcast" ) ) session.add( PendingTransaction( - kind="bet", round_id=11, user_id=7, current_txid="bumped-txid", fee_rate_sat_vb=2, + kind="bet", round_id=11, user_id=user.id, current_txid="bumped-txid", fee_rate_sat_vb=2, raw_tx_hex="00", status="pending", replaced_by_txid="old-txid", ) ) @@ -171,3 +235,35 @@ async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factor async with session_factory() as session: assert (await session.get(Round, 12)).status == "closed" + + +async def test_poll_once_caches_history_per_scripthash(session_factory): + """Two pending bets from the same user share one address — fetching its + history twice in one pass would be wasteful.""" + async with session_factory() as session: + user = await _make_user(session, 0) + session.add(Round(id=20, status="open")) + session.add( + PendingTransaction( + kind="bet", round_id=20, user_id=user.id, current_txid="tx-a", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) + session.add( + PendingTransaction( + kind="withdrawal", user_id=user.id, current_txid="tx-b", fee_rate_sat_vb=1, + raw_tx_hex="00", status="pending", + ) + ) + await session.commit() + + call_count = {"n": 0} + + class CountingClient: + async def get_history(self, scripthash: str) -> list[dict]: + call_count["n"] += 1 + return [{"tx_hash": "tx-a", "height": 0}, {"tx_hash": "tx-b", "height": 0}] + + await poll_once(session_factory, CountingClient()) + + assert call_count["n"] == 1 diff --git a/tests/unit/test_db_base.py b/tests/unit/test_db_base.py new file mode 100644 index 0000000..3a172c8 --- /dev/null +++ b/tests/unit/test_db_base.py @@ -0,0 +1,61 @@ +"""Regression tests for B-39: SQLite must run in WAL mode with a busy_timeout, +since this app has five concurrent background tasks plus every HTTP handler +sharing one database file, and the default rollback-journal mode lets a writer +block every reader and fails a second writer immediately instead of waiting.""" + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +from app.db.base import _SQLITE_BUSY_TIMEOUT_MS, _register_sqlite_pragmas + + +@pytest.fixture +async def sqlite_engine(tmp_path): + # WAL needs a real file (it writes a companion -wal/-shm file alongside it) — + # ":memory:" wouldn't exercise the same path. + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/test.db") + yield engine + await engine.dispose() + + +async def _pragma(engine, name: str): + async with engine.connect() as conn: + result = await conn.exec_driver_sql(f"PRAGMA {name}") + return result.fetchone()[0] + + +async def test_register_sqlite_pragmas_enables_wal_and_busy_timeout(sqlite_engine): + _register_sqlite_pragmas(sqlite_engine) + + assert (await _pragma(sqlite_engine, "journal_mode")).lower() == "wal" + assert await _pragma(sqlite_engine, "busy_timeout") == _SQLITE_BUSY_TIMEOUT_MS + assert await _pragma(sqlite_engine, "synchronous") == 1 # NORMAL + + +async def test_register_sqlite_pragmas_applies_to_every_new_connection(sqlite_engine): + """The pool can open more than one underlying DBAPI connection over the + engine's lifetime — the pragmas must be re-applied to each one, not just + the first, or a later connection would silently fall back to SQLite's + defaults.""" + _register_sqlite_pragmas(sqlite_engine) + + async with sqlite_engine.connect() as first: + await first.exec_driver_sql("PRAGMA journal_mode") + + async with sqlite_engine.connect() as second: + result = await second.exec_driver_sql("PRAGMA busy_timeout") + assert result.fetchone()[0] == _SQLITE_BUSY_TIMEOUT_MS + + +def test_register_sqlite_pragmas_is_a_noop_for_other_dialects(): + """Must not touch (or crash on) a non-sqlite engine — e.g. a future + PostgreSQL DATABASE_URL, which neither needs nor understands these + pragmas.""" + + class _FakeDialect: + name = "postgresql" + + class _FakeEngine: + dialect = _FakeDialect() + + _register_sqlite_pragmas(_FakeEngine()) # must not raise diff --git a/tests/unit/test_deposit_reconcile.py b/tests/unit/test_deposit_reconcile.py new file mode 100644 index 0000000..824e2e8 --- /dev/null +++ b/tests/unit/test_deposit_reconcile.py @@ -0,0 +1,98 @@ +"""Regression tests for B-30: a periodic sweep must catch a deposit whose +scripthash notification was silently lost, independent of whatever the +notification-driven path (electrum/listener.py:refresh_user) is doing.""" + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.db.base import Base +from app.db.models import User +from app.deposits.reconcile import DepositReconciler + + +@pytest.fixture +async def session_factory(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + + +async def _seed_users(session_factory, addresses: list[str]) -> list[int]: + async with session_factory() as session: + ids = [] + for i, address in enumerate(addresses): + user = User(username=f"user{i}", password_hash="x", derivation_index=i, address=address) + session.add(user) + await session.flush() + ids.append(user.id) + await session.commit() + return ids + + +# Real, decodable PLM bech32 addresses (address_to_scripthash actually parses +# them) — arbitrary otherwise. +_ADDRESSES = [ + "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd", + "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n", + "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx", +] + + +class FakeListener: + def __init__(self, *, fail_for: set[int] | None = None, disconnect_after: int | None = None): + self.client = object() # truthy: "connected" + self.refreshed: list[int] = [] + self._fail_for = fail_for or set() + self._disconnect_after = disconnect_after + + async def refresh_user(self, user_id: int, scripthash: str) -> None: + self.refreshed.append(user_id) + if self._disconnect_after is not None and len(self.refreshed) >= self._disconnect_after: + self.client = None + if user_id in self._fail_for: + raise RuntimeError(f"listunspent failed for user {user_id}") + + +async def test_sweep_once_refreshes_every_user(session_factory): + user_ids = await _seed_users(session_factory, _ADDRESSES) + listener = FakeListener() + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() + + assert listener.refreshed == user_ids + + +async def test_sweep_once_continues_past_a_failing_user(session_factory): + """One user's refresh failing (a transient network hiccup) must not stop the + sweep from reaching the rest — mirrors poll_once's per-item isolation.""" + user_ids = await _seed_users(session_factory, _ADDRESSES) + listener = FakeListener(fail_for={user_ids[1]}) + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() + + assert listener.refreshed == user_ids + + +async def test_sweep_once_stops_when_the_connection_drops_mid_sweep(session_factory): + """No point continuing once the connection is gone — the next reconnect's own + _subscribe_all_users will cover everyone anyway.""" + user_ids = await _seed_users(session_factory, _ADDRESSES) + listener = FakeListener(disconnect_after=1) + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() + + assert listener.refreshed == user_ids[:1] + + +async def test_sweep_once_does_nothing_with_no_users(session_factory): + listener = FakeListener() + reconciler = DepositReconciler(session_factory, listener) + + await reconciler._sweep_once() # must not raise + + assert listener.refreshed == [] diff --git a/tests/unit/test_deposits.py b/tests/unit/test_deposits.py index 299ec9c..d744916 100644 --- a/tests/unit/test_deposits.py +++ b/tests/unit/test_deposits.py @@ -4,7 +4,12 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.db.models import AuditLog, User, UtxoEvent -from app.deposits.service import credit_confirmed_utxos, detect_external_spends +from app.deposits.service import ( + credit_confirmed_utxos, + find_utxos_missing_from, + mark_utxos_spent_externally, + reinstate_reappeared_utxos, +) @pytest.fixture @@ -55,14 +60,66 @@ async def test_idempotent_on_repeated_notification(session_factory, user_id): assert user.cached_balance_sats == 7_000_000 -async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_factory, user_id): - entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] +# --- B-29: detecting a UTXO spent outside the platform is now a three-step, +# corroborate-before-you-mark process, split across find_utxos_missing_from +# (read-only candidate detection), the caller's own corroboration against other +# servers (electrum/listener.py, not exercised here), and mark_utxos_spent_ +# externally (persistence only, once a candidate is already confirmed). --------- + + +async def test_find_utxos_missing_from_returns_the_missing_candidate(session_factory, user_id): + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + + # A different outpoint present in this refresh — our own tracked one is + # genuinely absent from it, not just from an entirely empty reply. + other_entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}] + async with session_factory() as session: + candidates = await find_utxos_missing_from(session, user_id, other_entries) + assert len(candidates) == 1 + assert candidates[0].txid == "dd" * 32 + assert candidates[0].spent_txid is None # read-only: nothing is marked yet + + +async def test_find_utxos_missing_from_returns_nothing_when_present(session_factory, user_id): + entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}] async with session_factory() as session: await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: - spent = await detect_external_spends(session, user_id, []) - assert spent == 1 + candidates = await find_utxos_missing_from(session, user_id, entries) + assert candidates == [] + user = await session.get(User, user_id) + assert user.cached_balance_sats == 3_000_000 + + +async def test_find_utxos_missing_from_skips_a_totally_empty_response(session_factory, user_id): + """B-29: an entirely empty listunspent for a funded address reads as an + incomplete/broken response, not proof of a full external sweep — it would + otherwise flag every UTXO of this user as missing from one bad reply.""" + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + + async with session_factory() as session: + candidates = await find_utxos_missing_from(session, user_id, []) + assert candidates == [] + + +async def test_mark_utxos_spent_externally_marks_and_corrects_balance(session_factory, user_id): + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + + async with session_factory() as session: + utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() + marked = await mark_utxos_spent_externally(session, user_id, [utxo.id]) + assert marked == 1 + user = await session.get(User, user_id) assert user.cached_balance_sats == 0 @@ -73,13 +130,57 @@ async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_fact assert any(e.event_type == "utxo_spent_externally" for e in audit_events) -async def test_no_spend_detected_when_utxo_still_unspent(session_factory, user_id): +async def test_mark_utxos_spent_externally_skips_an_already_resolved_row(session_factory, user_id): + """Something else (a legitimate platform spend, or a prior refresh) may have + resolved the row between the caller reading the candidate list and finishing + corroboration — mark_utxos_spent_externally must not clobber that.""" + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() + utxo_id = utxo.id + utxo.spent_txid = "some-real-platform-txid" + await session.commit() + + async with session_factory() as session: + marked = await mark_utxos_spent_externally(session, user_id, [utxo_id]) + assert marked == 0 + utxo = await session.get(UtxoEvent, utxo_id) + assert utxo.spent_txid == "some-real-platform-txid" # untouched + + +async def test_reinstate_reappeared_utxos_clears_the_mark_and_restores_balance(session_factory, user_id): + async with session_factory() as session: + await credit_confirmed_utxos( + session, user_id, [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + ) + utxo_id = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().id + + async with session_factory() as session: + await mark_utxos_spent_externally(session, user_id, [utxo_id]) + + # The outpoint reappears as unspent in a later refresh. + entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + async with session_factory() as session: + reinstated = await reinstate_reappeared_utxos(session, user_id, entries) + assert reinstated == 1 + + user = await session.get(User, user_id) + assert user.cached_balance_sats == 20_000_000 + + utxo = await session.get(UtxoEvent, utxo_id) + assert utxo.spent_txid is None + + audit_events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert "utxo_external_spend_reinstated" in audit_events + + +async def test_reinstate_reappeared_utxos_ignores_unmarked_rows(session_factory, user_id): entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}] async with session_factory() as session: await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: - spent = await detect_external_spends(session, user_id, entries) - assert spent == 0 - user = await session.get(User, user_id) - assert user.cached_balance_sats == 3_000_000 + reinstated = await reinstate_reappeared_utxos(session, user_id, entries) + assert reinstated == 0 diff --git a/tests/unit/test_docs_disabled.py b/tests/unit/test_docs_disabled.py new file mode 100644 index 0000000..c9657a3 --- /dev/null +++ b/tests/unit/test_docs_disabled.py @@ -0,0 +1,28 @@ +"""B-42: Swagger/ReDoc/OpenAPI JSON must not be reachable unless explicitly enabled — +they enumerate the whole API surface, admin endpoints included.""" + +import importlib + +from app.config import settings + + +def _reload_main(): + import app.main + + return importlib.reload(app.main) + + +def test_docs_disabled_by_default(monkeypatch): + monkeypatch.setattr(settings, "enable_api_docs", False) + main = _reload_main() + assert main.app.docs_url is None + assert main.app.redoc_url is None + assert main.app.openapi_url is None + + +def test_docs_enabled_when_configured(monkeypatch): + monkeypatch.setattr(settings, "enable_api_docs", True) + main = _reload_main() + assert main.app.docs_url == "/docs" + assert main.app.redoc_url == "/redoc" + assert main.app.openapi_url == "/openapi.json" diff --git a/tests/unit/test_docs_no_bare_uvicorn.py b/tests/unit/test_docs_no_bare_uvicorn.py new file mode 100644 index 0000000..7999530 --- /dev/null +++ b/tests/unit/test_docs_no_bare_uvicorn.py @@ -0,0 +1,18 @@ +"""B-44: README and docs/running-the-server.md must not document a bare +`uvicorn --reload` workflow — the server always runs via Docker, in dev and +production alike (CLAUDE.md's "Commands" section), and the two files had +drifted back to contradicting that policy.""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent.parent + + +def test_readme_has_no_bare_uvicorn_command(): + readme = (REPO_ROOT / "README.md").read_text() + assert "uvicorn app.main:app --reload" not in readme + + +def test_running_the_server_doc_has_no_bare_uvicorn_command(): + doc = (REPO_ROOT / "docs" / "running-the-server.md").read_text() + assert "uvicorn app.main:app --reload" not in doc diff --git a/tests/unit/test_draw.py b/tests/unit/test_draw.py index 4f1bea6..c2a63c1 100644 --- a/tests/unit/test_draw.py +++ b/tests/unit/test_draw.py @@ -1,19 +1,50 @@ import pytest -from app.rounds.draw import draw_winner, header_hex_to_block_hash +from app.rounds.draw import ( + draw_winner, + header_hex_to_block_hash, + header_meets_its_own_target, + header_prev_hash, +) + +# Real PLM mainnet block 477486: header from blockchain.block.header, hash +# cross-checked against the blockhash reported by blockchain.transaction.get for a +# tx confirmed in that block. +_REAL_HEADER_HEX = ( + "0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000" + "009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875" + "e6a47a61b1ad8012582" +) def test_header_hex_to_block_hash_matches_known_mainnet_block(): - # Real PLM mainnet block 477486: header from blockchain.block.header, hash - # cross-checked against the blockhash reported by blockchain.transaction.get - # for a tx confirmed in that block. - header_hex = ( - "0020ed30fbc39ee45200d10214f5107c5f36e7bf753032fd1d3279810c170000000000" - "009a4ea723f732be4c538f3a2490837dd6560103d73ece606aa7d578a66700447b28875" - "e6a47a61b1ad8012582" - ) known_block_hash = "00000000000008788b55ade13b74d54ceffda9e54315b802411be1ca65064e86" - assert header_hex_to_block_hash(header_hex) == known_block_hash + assert header_hex_to_block_hash(_REAL_HEADER_HEX) == known_block_hash + + +def test_header_meets_its_own_target_accepts_a_real_mined_header(): + """B-28: a genuinely mined mainnet header must pass its own self-consistency + check — this isn't just a synthetic-header property.""" + assert header_meets_its_own_target(_REAL_HEADER_HEX) is True + + +def test_header_meets_its_own_target_rejects_a_tampered_header(): + """Flipping a single nonce bit changes the hash completely (avalanche effect) + without changing the claimed difficulty, so a tampered-but-otherwise-real + header should almost certainly fail — this is what would catch a + hostile/MITM'd server replaying a real header with a doctored field.""" + tampered = bytearray(bytes.fromhex(_REAL_HEADER_HEX)) + tampered[-1] ^= 0xFF # flip the last byte of the nonce + assert header_meets_its_own_target(tampered.hex()) is False + + +def test_header_meets_its_own_target_rejects_wrong_length(): + assert header_meets_its_own_target("aa" * 10) is False + + +def test_header_prev_hash_matches_the_known_previous_block(): + # Block 477486's predecessor, 477485 — independently known from the same chain. + assert header_prev_hash(_REAL_HEADER_HEX) == "000000000000170c8179321dfd323075bfe7365f7c10f51402d10052e49ec3fb" def test_draw_winner_is_deterministic_and_within_range(): diff --git a/tests/unit/test_electrum_listener.py b/tests/unit/test_electrum_listener.py index d0a63ca..2270254 100644 --- a/tests/unit/test_electrum_listener.py +++ b/tests/unit/test_electrum_listener.py @@ -1,5 +1,8 @@ """Listener-level behaviour: server rotation on failure (the fallback-servers -feature), and the chain-tip monotonicity guard (B-19). +feature), the chain-tip monotonicity guard (B-19), header validation and +multi-server corroboration (B-28), the new-user subscribe task's retention and +error logging (B-30), and bounded-concurrency, non-blocking resubscribe on +reconnect (B-31). The reconnect loop itself (B-01) is covered from the client side in test_electrum_client.py — what's asserted here is that the listener *acts* on a @@ -7,13 +10,18 @@ dead connection by moving to the next server instead of retrying the same one. """ import asyncio +import logging +import struct import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base +from app.db.models import User, UtxoEvent from app.electrum.client import ElectrumEndpoint from app.electrum.listener import ElectrumListener +from app.rounds.draw import HeaderValidationError, header_hex_to_block_hash, header_meets_its_own_target @pytest.fixture @@ -31,6 +39,34 @@ _ENDPOINTS = [ ElectrumEndpoint("third.example", 50001, False), ] +# A regtest-style trivial difficulty target (~50% of hashes satisfy it), so mining +# a real, self-consistent test header takes a handful of nonce attempts rather than +# needing actual mainnet-grade hashpower. Not a valid PLM mainnet difficulty — +# irrelevant here, since header_meets_its_own_target only checks self-consistency. +_EASY_BITS = 0x207FFFFF + + +def _build_header(prev_hash_hex: str, nonce: int, *, bits: int = _EASY_BITS) -> str: + return ( + struct.pack(" str: + """A real header that satisfies its own claimed target — good enough to + exercise header_meets_its_own_target/_apply_header for real, without needing + genuine PLM-mainnet-grade hashpower.""" + for nonce in range(100_000): + header_hex = _build_header(prev_hash_hex, nonce, bits=bits) + if header_meets_its_own_target(header_hex): + return header_hex + raise RuntimeError("failed to mine a test header within the attempt budget") + async def test_rotates_to_the_next_server_after_a_failed_session(session_factory): """One unreachable server should cost a single attempt, not an outage: every @@ -124,6 +160,40 @@ async def test_listener_with_no_endpoints_gives_up_loudly(session_factory): assert listener.current_endpoint is None +# --- B-30: address_for_new_user's subscribe task must be retained (not fire-and- +# forget) and its failure must be observable, not silently swallowed. ------------- + + +async def test_address_for_new_user_does_nothing_without_a_connection(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") # listener.client is None + assert listener._background_tasks == set() + + +async def test_address_for_new_user_retains_and_logs_a_failed_subscribe_task(session_factory, caplog): + """Before B-30, this task was fire-and-forget: an AssertionError (self.client + turning None mid-flight) or any other failure vanished into asyncio's default + unretrieved-exception handler instead of being logged anywhere the operator + could see, and nothing kept the task alive in the meantime.""" + + class FailingClient: + async def subscribe_scripthash(self, scripthash): + raise ConnectionResetError("dropped mid-subscribe") + + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + listener.client = FailingClient() + + listener.address_for_new_user(1, "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") + assert len(listener._background_tasks) == 1 # retained while in flight + + with caplog.at_level(logging.WARNING): + await asyncio.gather(*list(listener._background_tasks), return_exceptions=True) + await asyncio.sleep(0) # let the done_callbacks (scheduled via call_soon) run + + assert listener._background_tasks == set() # discarded once done + assert "could not subscribe" in caplog.text + + def test_tip_never_moves_backwards(session_factory): """B-19: `self.tip_height = header["height"]` accepted a lower height, and _wait_for_next_block waits for tip_height > tip_at_close — so a regression @@ -132,11 +202,431 @@ def test_tip_never_moves_backwards(session_factory): a stale one.""" listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) - listener._apply_header({"height": 100, "hex": "aa"}) - assert (listener.tip_height, listener.tip_header_hex) == (100, "aa") + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) - listener._apply_header({"height": 99, "hex": "bb"}) # reorg, or a server switch - assert (listener.tip_height, listener.tip_header_hex) == (100, "aa") + # A lower height is ignored purely on height, before any header validation even + # runs — reorg or server switch, not a real advance. + listener._apply_header({"height": 99, "hex": "bb"}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) - listener._apply_header({"height": 101, "hex": "cc"}) - assert (listener.tip_height, listener.tip_header_hex) == (101, "cc") + header_101 = _mine_header(header_hex_to_block_hash(header_100)) + listener._apply_header({"height": 101, "hex": header_101}) + assert (listener.tip_height, listener.tip_header_hex) == (101, header_101) + + +# --- B-28: a hostile or MITM'd server can no longer single-handedly decide the +# draw's entropy — header self-consistency/linkage checks, and multi-server +# corroboration for the block the draw actually uses. --------------------------- + + +def test_apply_header_rejects_one_that_fails_its_own_pow_target(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + # Real mainnet-grade difficulty (genesis-era Bitcoin bits): satisfying it by + # chance is astronomically unlikely, so this header is self-inconsistent. + forged = _build_header("00" * 32, nonce=0, bits=0x1D00FFFF) + + with pytest.raises(HeaderValidationError): + listener._apply_header({"height": 100, "hex": forged}) + assert (listener.tip_height, listener.tip_header_hex) == (0, None) # untouched + + +def test_apply_header_rejects_one_that_does_not_chain_from_the_tip(session_factory): + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + + # A single-block advance (101 = 100 + 1) whose prev_block claims an unrelated + # chain — well-formed and self-consistently mined, but not actually built on + # top of our current tip. + disconnected = _mine_header("ff" * 32) + + with pytest.raises(HeaderValidationError): + listener._apply_header({"height": 101, "hex": disconnected}) + assert (listener.tip_height, listener.tip_header_hex) == (100, header_100) # untouched + + +def test_apply_header_skips_linkage_check_across_a_height_gap(session_factory): + """A reconnect (or the very first header of a session) hands us whatever the + server's current tip is — which is legitimately not a single-block advance + from whatever we last saw. There's no full header chain to check linkage + against in that case, so only self-consistency is enforced.""" + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + header_100 = _mine_header("00" * 32) + listener._apply_header({"height": 100, "hex": header_100}) + + header_150 = _mine_header("ff" * 32) # unrelated prev_block, height jumps by 50 + listener._apply_header({"height": 150, "hex": header_150}) # must not raise + + assert (listener.tip_height, listener.tip_header_hex) == (150, header_150) + + +async def _endpoint_client_factory(responses: dict[str, object]): + """Builds a client_factory whose fake clients answer blockchain.block.header + per-endpoint according to `responses`: a header hex string to agree/disagree + with, `None` to simulate an unreachable server, or an Exception instance to + simulate a request failure.""" + + class _FakeClient: + def __init__(self, answer): + self._answer = answer + self.closed = False + + async def connect(self): + if isinstance(self._answer, Exception): + raise self._answer + + async def request(self, method, params): + assert method == "blockchain.block.header" + if self._answer is None: + raise ConnectionRefusedError("unreachable") + return self._answer + + async def close(self): + self.closed = True + + def factory(endpoint): + return _FakeClient(responses[endpoint.host]) + + return factory + + +async def test_corroborate_header_true_with_no_other_servers_configured(session_factory): + single = [ElectrumEndpoint("only.example", 50002, True)] + listener = ElectrumListener(lambda endpoint: None, session_factory, single) + assert await listener.corroborate_header(100, "deadbeef") is True + + +async def test_corroborate_header_true_when_others_agree(session_factory): + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + factory = await _endpoint_client_factory( + {"first.example": header_hex, "second.example": header_hex, "third.example": header_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, expected_hash) is True + + +async def test_corroborate_header_never_asks_the_currently_active_endpoint(session_factory): + """The active connection is exactly what a hostile server or a MITM would + control — corroborating against it too would defeat the point.""" + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + # first.example (the active endpoint) would raise if ever queried. + factory = await _endpoint_client_factory( + {"first.example": RuntimeError("must not be called"), "second.example": header_hex, "third.example": header_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + assert listener.current_endpoint.host == "first.example" + + assert await listener.corroborate_header(100, expected_hash) is True + + +async def test_corroborate_header_false_when_majority_disagrees(session_factory): + header_hex = _mine_header("00" * 32) + expected_hash = header_hex_to_block_hash(header_hex) + disagreeing_hex = _mine_header("11" * 32) + factory = await _endpoint_client_factory( + {"first.example": header_hex, "second.example": disagreeing_hex, "third.example": disagreeing_hex} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, expected_hash) is False + + +async def test_corroborate_header_false_when_nobody_responds(session_factory): + factory = await _endpoint_client_factory( + {"first.example": "irrelevant", "second.example": None, "third.example": ConnectionRefusedError("down")} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_header(100, "deadbeef") is False + + +# --- B-29: a UTXO absent from our own connection's listunspent must be +# corroborated by other configured servers before it's treated as genuinely spent +# outside the platform. ------------------------------------------------------------ + + +async def _listunspent_client_factory(responses: dict[str, object]): + """Builds a client_factory whose fake clients answer listunspent per-endpoint: + a list of entries to report as unspent, `None` to simulate an unreachable + server (fails at listunspent), or an Exception instance to simulate a connect + failure.""" + + class _FakeClient: + def __init__(self, answer): + self._answer = answer + + async def connect(self): + if isinstance(self._answer, Exception): + raise self._answer + + async def listunspent(self, scripthash): + if self._answer is None: + raise ConnectionRefusedError("unreachable") + return self._answer + + async def close(self): + pass + + def factory(endpoint): + return _FakeClient(responses[endpoint.host]) + + return factory + + +async def test_corroborate_utxo_spent_true_with_no_other_servers_configured(session_factory): + single = [ElectrumEndpoint("only.example", 50002, True)] + listener = ElectrumListener(lambda endpoint: None, session_factory, single) + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True + + +async def test_corroborate_utxo_spent_true_when_others_agree_its_gone(session_factory): + factory = await _listunspent_client_factory({"first.example": [], "second.example": [], "third.example": []}) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is True + + +async def test_corroborate_utxo_spent_false_when_majority_still_see_it_unspent(session_factory): + still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}] + factory = await _listunspent_client_factory( + {"first.example": [], "second.example": still_there, "third.example": still_there} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False + + +async def test_corroborate_utxo_spent_false_when_nobody_responds(session_factory): + factory = await _listunspent_client_factory( + {"first.example": [], "second.example": None, "third.example": ConnectionRefusedError("down")} + ) + listener = ElectrumListener(factory, session_factory, _ENDPOINTS) + + assert await listener.corroborate_utxo_spent("scripthash", "dd" * 32, 0) is False + + +class _ActiveClient: + """Stands in for `self.client`, the listener's one active connection — + refresh_user only ever calls listunspent on it.""" + + def __init__(self, entries: list[dict]): + self._entries = entries + + async def listunspent(self, scripthash): + return self._entries + + +async def _seed_funded_user(session_factory, *, username: str, address: str) -> int: + from app.wallet.balance import recompute_balance + + async with session_factory() as session: + user = User(username=username, password_hash="x", derivation_index=0, address=address) + session.add(user) + await session.commit() + session.add( + UtxoEvent(user_id=user.id, txid="dd" * 32, vout=0, amount_sats=20_000_000, confirmed_height=100) + ) + await recompute_balance(session, user.id) + await session.commit() + return user.id + + +# An unrelated outpoint present alongside our own connection's listunspent reply — +# keeps `entries` non-empty so find_utxos_missing_from's "entirely empty response" +# guard doesn't swallow these tests; our own tracked UTXO is still genuinely +# absent from it. +_UNRELATED_ENTRY = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 100, "value": 1_000_000}] + + +async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(session_factory): + user_id = await _seed_funded_user(session_factory, username="bob", address="plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd") + + others_factory = await _listunspent_client_factory( + {"first.example": [], "second.example": [], "third.example": []} + ) + listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS) + listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either + + await listener.refresh_user(user_id, "scripthash") + + async with session_factory() as session: + utxo = ( + await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32)) + ).one() + assert utxo.spent_txid == "external-spend" + user = await session.get(User, user_id) + # The original 20_000_000 is spent; the unrelated entry the "active" + # connection also reported gets freshly credited alongside it. + assert user.cached_balance_sats == 1_000_000 + + +async def test_refresh_user_does_not_mark_when_corroboration_fails(session_factory): + """The single most important case: our own connection alone reporting the + UTXO missing must not be enough — before B-29 this zeroed the balance on one + bad reply.""" + user_id = await _seed_funded_user(session_factory, username="carol", address="plm1qtest2") + + still_there = [{"tx_hash": "dd" * 32, "tx_pos": 0}] + others_factory = await _listunspent_client_factory( + {"first.example": [], "second.example": still_there, "third.example": still_there} + ) + listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS) + listener.client = _ActiveClient(_UNRELATED_ENTRY) + + await listener.refresh_user(user_id, "scripthash") + + async with session_factory() as session: + utxo = ( + await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id, UtxoEvent.txid == "dd" * 32)) + ).one() + assert utxo.spent_txid is None + user = await session.get(User, user_id) + # Untouched, plus the unrelated entry credited alongside it. + assert user.cached_balance_sats == 21_000_000 + + +# --- B-31: resubscribing on reconnect must be bounded-concurrency and must not +# block tip updates (and so an in-flight draw) for its entire duration. ----------- + + +def _fake_address(i: int) -> str: + """A real, decodable PLM bech32 P2WPKH address (address_to_scripthash + actually parses it) — distinct per index, since User.address is unique.""" + from embit import script + + from app.wallet.plm_network import PLM_MAINNET + + payload = (i + 1).to_bytes(20, "big") + return script.Script(b"\x00\x14" + payload).address(network=PLM_MAINNET) + + +async def _seed_users(session_factory, count: int) -> None: + async with session_factory() as session: + for i in range(count): + session.add( + User(username=f"user{i}", password_hash="x", derivation_index=i, address=_fake_address(i)) + ) + await session.commit() + + +async def test_subscribe_all_users_bounds_concurrency(session_factory): + """B-31: at thousands of users, subscribing one at a time meant thousands of + sequential round-trips. Concurrency must be bounded (not unlimited either — + a huge user base shouldn't open thousands of simultaneous requests).""" + user_count = 45 + await _seed_users(session_factory, user_count) + + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + in_flight = 0 + max_in_flight = 0 + calls = [] + + async def fake_subscribe_and_refresh(scripthash, user_id): + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + calls.append(user_id) + await asyncio.sleep(0) # yield, so genuinely-concurrent calls interleave + in_flight -= 1 + + listener._subscribe_and_refresh = fake_subscribe_and_refresh + + await listener._subscribe_all_users() + + assert len(calls) == user_count + assert 1 < max_in_flight <= 20 # bounded, and actually concurrent (not serial) + + +async def test_subscribe_all_users_continues_past_a_failing_user(session_factory): + await _seed_users(session_factory, 5) + listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) + succeeded = [] + + async def flaky_subscribe_and_refresh(scripthash, user_id): + if user_id == 3: + raise ConnectionResetError("dropped mid-subscribe") + succeeded.append(user_id) + + listener._subscribe_and_refresh = flaky_subscribe_and_refresh + + await listener._subscribe_all_users() # must not raise + + assert succeeded == [1, 2, 4, 5] + + +class _FakeConnectClient: + """A minimally-real ElectrumClient double: enough of connect/subscribe/notify/ + ping/wait_closed/close to drive ElectrumListener._run_once end-to-end.""" + + def __init__(self, header: dict): + self._header = header + self._queues: dict[str, asyncio.Queue] = {} + self._closed = asyncio.Event() + + async def connect(self): + pass + + async def subscribe_headers(self): + return self._header + + def notifications(self, method: str) -> asyncio.Queue: + return self._queues.setdefault(method, asyncio.Queue()) + + async def ping(self): + pass + + async def wait_closed(self): + await self._closed.wait() + + async def close(self): + self._closed.set() + + +async def _wait_until(predicate, *, timeout: float = 2.0, interval: float = 0.01) -> None: + async def _poll(): + while not predicate(): + await asyncio.sleep(interval) + + await asyncio.wait_for(_poll(), timeout=timeout) + + +async def test_run_once_keeps_consuming_headers_while_resubscribing(session_factory): + """The core B-31 fix: before this, _subscribe_all_users ran to completion + *before* the header-consuming task even started, so a reconnect with many + users froze tip_height — and so _wait_for_next_block's draw wait — for the + entire resubscribe. It must now keep advancing while resubscribing is still + in flight.""" + await _seed_users(session_factory, 3) + + header_hex = _mine_header("00" * 32) + client = _FakeConnectClient({"height": 100, "hex": header_hex}) + listener = ElectrumListener(lambda endpoint: client, session_factory, _ENDPOINTS) + + subscribe_started = asyncio.Event() + + async def blocked_subscribe_and_refresh(scripthash, user_id): + subscribe_started.set() + await asyncio.sleep(3600) # simulates a slow sweep; cancelled on cleanup + + listener._subscribe_and_refresh = blocked_subscribe_and_refresh + + run_once_task = asyncio.create_task(listener._run_once(_ENDPOINTS[0])) + try: + await asyncio.wait_for(subscribe_started.wait(), timeout=2) + + # Resubscribing is still stuck mid-flight — but a new tip must still be + # processed, proving the header consumer isn't blocked behind it. + headers_queue = client.notifications("blockchain.headers.subscribe") + next_header_hex = _mine_header(header_hex_to_block_hash(header_hex)) + await headers_queue.put([{"height": 101, "hex": next_header_hex}]) + await _wait_until(lambda: listener.tip_height == 101) + + assert listener.tip_header_hex == next_header_hex + finally: + await client.close() + await run_once_task diff --git a/tests/unit/test_models_text_columns.py b/tests/unit/test_models_text_columns.py new file mode 100644 index 0000000..c2ac5c4 --- /dev/null +++ b/tests/unit/test_models_text_columns.py @@ -0,0 +1,16 @@ +"""B-47: raw_tx_hex (a full raw signed transaction hex) and payload_json (an +arbitrary audit payload) must stay `Text`, not a bare `String`/`VARCHAR` with +no length -- SQLite and PostgreSQL accept that, but other backends (e.g. +MySQL) require a length on VARCHAR and would reject it.""" + +from sqlalchemy import Text + +from app.db.models import AuditLog, PendingTransaction + + +def test_pending_transaction_raw_tx_hex_is_text(): + assert isinstance(PendingTransaction.__table__.c.raw_tx_hex.type, Text) + + +def test_audit_log_payload_json_is_text(): + assert isinstance(AuditLog.__table__.c.payload_json.type, Text) diff --git a/tests/unit/test_pending_address.py b/tests/unit/test_pending_address.py new file mode 100644 index 0000000..a807f7f --- /dev/null +++ b/tests/unit/test_pending_address.py @@ -0,0 +1,55 @@ +"""B-41: own_address_for is the single place tx/confirmation.py and +tx/reconcile.py derive a PendingTransaction's own address from — a payout's +address must always be the pool's, everything else the actual user's.""" + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings +from app.db.base import Base +from app.db.models import User +from app.tx.pending_address import own_address_for + + +@pytest.fixture +async def session_factory(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + hd._account_key = None + + +async def test_payout_uses_the_pool_address_regardless_of_user_id(session_factory): + from app.wallet.hd import derive_pool_address + + async with session_factory() as session: + address = await own_address_for(session, "payout", None) + + assert address == derive_pool_address() + + +@pytest.mark.parametrize("kind", ["bet", "withdrawal"]) +async def test_bet_and_withdrawal_use_the_users_own_address(session_factory, kind): + from app.wallet.hd import derive_user_address + + async with session_factory() as session: + user = User(username="alice", password_hash="x", derivation_index=3, address=derive_user_address(3)) + session.add(user) + await session.flush() + + address = await own_address_for(session, kind, user.id) + + assert address == derive_user_address(3) diff --git a/tests/unit/test_psbt_builder.py b/tests/unit/test_psbt_builder.py index fcd0686..4f8eb6e 100644 --- a/tests/unit/test_psbt_builder.py +++ b/tests/unit/test_psbt_builder.py @@ -5,6 +5,7 @@ from embit.transaction import Transaction from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import ( + MAX_TX_INPUTS, InsufficientFundsError, Utxo, build_signed_transaction, @@ -36,6 +37,22 @@ def test_select_utxos_raises_when_insufficient(): select_utxos(utxos, target_sats=10_000_000) +def test_select_utxos_never_exceeds_the_input_cap(): # B-48 + # 200 dust-ish UTXOs that together cover the target, but only past the cap. + utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)] + with pytest.raises(InsufficientFundsError) as excinfo: + select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS + 1) + assert excinfo.value.code == "too_many_inputs" + assert excinfo.value.params == {"max_inputs": MAX_TX_INPUTS} + + +def test_select_utxos_allows_exactly_the_input_cap(): + utxos = [Utxo(f"{i:064x}", 0, 100_000) for i in range(200)] + selected, total = select_utxos(utxos, target_sats=100_000 * MAX_TX_INPUTS) + assert len(selected) == MAX_TX_INPUTS + assert total == 100_000 * MAX_TX_INPUTS + + def test_build_signed_transaction_deducts_fee_from_amount_not_change(): signer = _key(1) from_script = script.p2wpkh(signer.to_public()) diff --git a/tests/unit/test_reconcile.py b/tests/unit/test_reconcile.py index 3c757a3..2a47e49 100644 --- a/tests/unit/test_reconcile.py +++ b/tests/unit/test_reconcile.py @@ -1,5 +1,10 @@ """Regression tests for B-04 (and the "building" half of B-08): a transaction that -never made it onto the chain must give the coins back instead of freezing them.""" +never made it onto the chain must give the coins back instead of freezing them. + +Also covers B-41: existence/reconciliation checks go through +blockchain.scripthash.get_history rather than a verbose blockchain.transaction.get +reply, so the fake clients below implement get_history. +""" import pytest from embit import script @@ -7,37 +12,60 @@ from embit.transaction import Transaction, TransactionInput, TransactionOutput from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.config import settings from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal from app.tx.reconcile import reconcile_once class UnknownTxClient: - """A server that doesn't know any of the txids it's asked about.""" + """A server whose history for any address never includes our txid.""" - async def get_transaction(self, txid: str, verbose: bool = False): - raise RuntimeError(f"missing transaction {txid}") + async def get_history(self, scripthash: str) -> list[dict]: + return [] class KnownTxClient: - async def get_transaction(self, txid: str, verbose: bool = False): - return {"txid": txid, "confirmations": 0} + """A server whose history for the address includes our txid — mined or + still in the mempool doesn't matter for existence, only for confirmation + (which is tx/confirmation.py's concern, not reconcile.py's).""" + + def __init__(self, txid: str = "betxid"): + self._txid = txid + + async def get_history(self, scripthash: str) -> list[dict]: + return [{"tx_hash": self._txid, "height": 100}] class BrokenClient: """A transport failure — says nothing about whether the tx exists.""" - async def get_transaction(self, txid: str, verbose: bool = False): + async def get_history(self, scripthash: str) -> list[dict]: raise ConnectionResetError("connection reset") @pytest.fixture -async def session_factory(): +async def session_factory(tmp_path, monkeypatch): + # own_address_for (B-41) derives each row's address via the HD wallet rather + # than trusting the DB's address column, so reconcile_once now needs a real + # master key set up — same bootstrap test_broadcast.py uses. + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() + hd._account_key = None # A real (unsigned) transaction spending one input, built rather than hand-written @@ -59,11 +87,26 @@ _RAW_TX = ( ) -async def _seed_bet(session_factory, *, pending_status: str, participant_status: str, age_seconds: int): +async def _seed_bet( + session_factory, + *, + pending_status: str, + participant_status: str, + age_seconds: int, + last_broadcast_age_seconds: int | None = None, + derivation_index: int = 0, +): from datetime import datetime, timedelta, timezone + from app.wallet.hd import derive_user_address + async with session_factory() as session: - user = User(username="u", password_hash="x", derivation_index=0, address="plm1qtest") + user = User( + username="u", + password_hash="x", + derivation_index=derivation_index, + address=derive_user_address(derivation_index), + ) session.add(user) await session.flush() session.add( @@ -85,6 +128,10 @@ async def _seed_bet(session_factory, *, pending_status: str, participant_status: status=participant_status, ) ) + # last_broadcast_age_seconds defaults to age_seconds (never bumped): the two + # timestamps only diverge in the B-27 regression test below, which simulates + # a tx that's been bumped recently but first appeared long ago. + last_age = age_seconds if last_broadcast_age_seconds is None else last_broadcast_age_seconds session.add( PendingTransaction( kind="bet", @@ -95,6 +142,7 @@ async def _seed_bet(session_factory, *, pending_status: str, participant_status: raw_tx_hex=_RAW_TX, status=pending_status, broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=age_seconds), + last_broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=last_age), ) ) await session.commit() @@ -133,7 +181,7 @@ async def test_promotes_a_building_row_whose_tx_did_reach_the_chain(session_fact session_factory, pending_status="building", participant_status="building", age_seconds=300 ) - resolved = await reconcile_once(session_factory, KnownTxClient()) + resolved = await reconcile_once(session_factory, KnownTxClient("betxid")) assert resolved == 1 async with session_factory() as session: @@ -167,6 +215,27 @@ async def test_leaves_a_recently_broadcast_pending_row_alone(session_factory): assert await reconcile_once(session_factory, UnknownTxClient()) == 0 +async def test_abandons_a_repeatedly_bumped_tx_despite_a_recent_last_broadcast(session_factory): + """B-27 regression: before last_broadcast_at existed, bump_fee overwrote + broadcast_at on every bump, which is the same field the abandon grace period is + measured from — so a tx first seen long ago but bumped minutes ago (exactly what + a stuck-but-repeatedly-bumped tx looks like) reset its own clock forever and was + never abandoned. The reconciler must still abandon it based on when it *first* + appeared, ignoring how recently it was last bumped.""" + await _seed_bet( + session_factory, + pending_status="pending", + participant_status="broadcast", + age_seconds=7 * 3600, # first broadcast 7h ago — past the 6h abandon window + last_broadcast_age_seconds=60, # bumped a minute ago + ) + + assert await reconcile_once(session_factory, UnknownTxClient()) == 1 + + async with session_factory() as session: + assert (await session.scalars(select(PendingTransaction))).one().status == "failed" + + async def test_transport_failure_never_abandons_anything(session_factory): """A dead connection says nothing about the transaction. Treating it as "gone" would release coins for transactions that are perfectly alive.""" @@ -186,8 +255,10 @@ async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): they can see it didn't go through.""" from datetime import datetime, timedelta, timezone + from app.wallet.hd import derive_user_address + async with session_factory() as session: - user = User(username="w", password_hash="x", derivation_index=1, address="plm1qtest2") + user = User(username="w", password_hash="x", derivation_index=1, address=derive_user_address(1)) session.add(user) await session.flush() session.add( @@ -231,3 +302,42 @@ async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): assert withdrawal.status == "failed" assert withdrawal.txid is None assert (await session.scalars(select(UtxoEvent))).one().spent_txid is None + + +# --- B-41: existence checks now use get_history and share it across candidates +# sharing the same address, instead of a per-tx verbose blockchain.transaction.get. -- + + +async def test_reconcile_once_caches_history_per_scripthash(session_factory): + """Two payout PendingTransaction rows always share the same pool address — + fetching its history twice in one pass would be wasteful and, at scale + across many candidates on one address, needlessly slow the whole tick.""" + from datetime import datetime, timedelta, timezone + + async with session_factory() as session: + old = datetime.now(timezone.utc) - timedelta(hours=7) + session.add( + PendingTransaction( + kind="payout", round_id=1, current_txid="payout-a", fee_rate_sat_vb=1, + raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old, + ) + ) + session.add( + PendingTransaction( + kind="payout", round_id=2, current_txid="payout-b", fee_rate_sat_vb=1, + raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old, + ) + ) + await session.commit() + + call_count = {"n": 0} + + class CountingClient: + async def get_history(self, scripthash: str) -> list[dict]: + call_count["n"] += 1 + return [{"tx_hash": "payout-a", "height": 100}, {"tx_hash": "payout-b", "height": 100}] + + resolved = await reconcile_once(session_factory, CountingClient()) + + assert resolved == 0 # both exist — nothing to abandon or promote (already "pending") + assert call_count["n"] == 1 # one call covered both rows sharing the pool address diff --git a/tests/unit/test_round_events.py b/tests/unit/test_round_events.py index 6425d06..7607fe8 100644 --- a/tests/unit/test_round_events.py +++ b/tests/unit/test_round_events.py @@ -2,7 +2,7 @@ import asyncio import pytest -from app.rounds.events import RoundEventBroadcaster, RoundEventCapacityError +from app.rounds.events import EVICTED, RoundEventBroadcaster, RoundEventCapacityError async def test_publish_wakes_up_subscriber(): @@ -60,3 +60,71 @@ async def test_unsubscribe_frees_a_capacity_slot(): broadcaster.unsubscribe(queue) broadcaster.subscribe() # no longer at capacity + + +# --- B-38: a single IP must not be able to exhaust the global cap and degrade +# every other user to polling. ---------------------------------------------------- + + +async def test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap(): + """Past MAX_SUBSCRIBERS_PER_IP, one more stream from the *same* IP evicts + that IP's own oldest connection rather than being refused — bounds one + source's footprint without an outright block.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2) + first = broadcaster.subscribe("1.2.3.4") + second = broadcaster.subscribe("1.2.3.4") + + third = broadcaster.subscribe("1.2.3.4") # past the per-IP cap of 2 + + assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED + assert second.empty() # untouched — only the oldest was evicted + assert third is not None + + +async def test_subscribe_does_not_evict_across_different_ips(): + """A different IP hitting its own cap must never evict an unrelated IP's + connection — that would let one abusive source crowd out real users, which + is exactly what the global-only cap used to allow.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1) + other_ip_queue = broadcaster.subscribe("9.9.9.9") + + broadcaster.subscribe("1.2.3.4") + broadcaster.subscribe("1.2.3.4") # evicts 1.2.3.4's own oldest, not 9.9.9.9's + + assert other_ip_queue.empty() + + +async def test_subscribe_still_enforces_the_global_cap_across_many_ips(): + """The per-IP cap doesn't replace the global backstop — spreading across + enough distinct IPs must still eventually hit MAX_SUBSCRIBERS.""" + broadcaster = RoundEventBroadcaster(max_subscribers=3, max_per_ip=1) + broadcaster.subscribe("1.1.1.1") + broadcaster.subscribe("2.2.2.2") + broadcaster.subscribe("3.3.3.3") + + with pytest.raises(RoundEventCapacityError): + broadcaster.subscribe("4.4.4.4") + + +async def test_unsubscribe_clears_the_per_ip_tracking_too(): + """Regression guard: unsubscribe must forget the queue's IP association, or + a churned-through connection would keep counting against that IP's cap + forever.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1) + queue = broadcaster.subscribe("1.2.3.4") + broadcaster.unsubscribe(queue) + + broadcaster.subscribe("1.2.3.4") # must not evict anything — nothing left to evict + assert queue.empty() + + +async def test_subscribe_defaults_to_a_shared_ip_when_none_given(): + """Existing callers (and most tests) that don't care about IP isolation + still share one implicit bucket rather than needing every call updated.""" + broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2) + first = broadcaster.subscribe() + broadcaster.subscribe() + + broadcaster.subscribe() # past the default bucket's cap of 2 — evicts, doesn't raise + + assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py index 6a675b8..6deedad 100644 --- a/tests/unit/test_rounds_route.py +++ b/tests/unit/test_rounds_route.py @@ -134,6 +134,36 @@ async def test_jackpot_comes_from_the_participants_actual_bets(client): assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100 +async def test_draw_waiting_since_is_exposed_only_while_drawing(client): + """B-36: the "drawing" wait on a future block has no timeout, so the frontend + needs draw_waiting_since to show "still waiting" instead of implying a bounded + countdown. It must not leak for any other status, where it's meaningless.""" + from datetime import datetime, timezone + + from app.db.models import Round, RoundConfig + + ac, session_factory = client + + started_at = datetime(2026, 7, 27, 10, 0, 0) + async with session_factory() as session: + session.add(RoundConfig(fee_address="")) + session.add(Round(id=60, status="drawing", drawing_started_at=started_at)) + await session.commit() + + body = (await ac.get("/rounds/current")).json() + assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00" + + async with session_factory() as session: + from sqlalchemy import select + + round_ = (await session.scalars(select(Round).where(Round.id == 60))).one() + round_.status = "paying_out" + await session.commit() + + body = (await ac.get("/rounds/current")).json() + assert body["draw_waiting_since"] is None + + async def test_unhandled_errors_use_the_structured_detail_shape(client): """B-24: the catch-all handler answered with a bare-string `detail`, while app/api/errors.py documents detail as {"code", "message", "params"}. Clients then diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 1aa4a9e..79990b7 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -4,9 +4,10 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.config import settings from app.db.base import Base -from app.db.models import Round, RoundConfig -from app.rounds.scheduler import RoundScheduler +from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User +from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints class FakeListener: @@ -52,3 +53,407 @@ async def test_tick_closes_round_with_no_participants_once_due(session_factory, async with session_factory() as session: round_ = (await session.scalars(select(Round))).one() assert round_.status == "closed" + + +# --- B-25: the payout must be persisted before it is broadcast, like bets/withdrawals --- + +# A real, reusable PLM bech32 address so build_payout_transaction's +# script.Script.from_address(...) succeeds — this is not a value the scheduler +# validates itself (that's the admin panel's job for fee_address), it just needs to +# actually decode. +_WINNER_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd" +_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n" + +_POOL_AMOUNT_SATS = 10_000_000_000 # 100 PLM + + +class FakePayoutClient: + def __init__(self, entries, *, fail_broadcast=False): + self._entries = entries + self._fail_broadcast = fail_broadcast + self.broadcasted: list[str] = [] + + async def listunspent(self, scripthash): + return self._entries + + async def broadcast(self, raw_tx_hex): + if self._fail_broadcast: + raise RuntimeError("node rejected the transaction") + self.broadcasted.append(raw_tx_hex) + return "network-txid" + + +class FakePayoutListener: + def __init__(self, client): + self.client = client + + +@pytest.fixture +async def payout_session_factory(tmp_path, monkeypatch): + """Same master-key bootstrap as test_broadcast.py's fixture: _trigger_payout + needs a real pool key to sign with.""" + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + monkeypatch.setattr( + settings, + "xprv_encryption_key", + __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode(), + ) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + hd._account_key = None + + +async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int: + async with session_factory() as session: + winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS) + session.add(winner) + await session.flush() + session.add(RoundConfig(fee_address=_FEE_ADDRESS, fee_rate_sat_vb=1)) + session.add( + Round( + id=round_id, + status="paying_out", + pool_amount_sats=_POOL_AMOUNT_SATS, + winner_user_id=winner.id, + ) + ) + await session.commit() + return winner.id + + +async def test_trigger_payout_persists_before_broadcasting(payout_session_factory): + """The happy path: payout_txid and a PendingTransaction must exist once the + broadcast succeeds, promoted from "building" to "pending" — the two-phase write + that used to be missing entirely (B-25).""" + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "33" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + assert client.broadcasted + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + assert round_.payout_txid is not None + assert round_.winner_amount_sats and round_.fee_amount_sats + + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.kind == "payout" + assert pending.status == "pending" + assert pending.current_txid == round_.payout_txid + + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert "payout_sent" in events + + +async def test_trigger_payout_broadcast_failure_leaves_a_recoverable_row(payout_session_factory): + """Before B-25, a broadcast rejection here left nothing behind — no payout_txid, + no PendingTransaction — because everything was persisted only after the + broadcast. Now the intent is already durable, so the reconciler has something to + resolve instead of the round being stuck with zero trace of what was attempted.""" + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries, fail_broadcast=True) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + assert not client.broadcasted + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + assert round_.payout_txid is not None # durable, even though the broadcast failed + + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.kind == "payout" + assert pending.status == "building" # not lost — the reconciler resolves this + assert pending.current_txid == round_.payout_txid + + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert "payout_failed" in events + + +async def test_trigger_payout_skips_when_already_in_flight(payout_session_factory): + """A second call for a round that already has a non-terminal payout + PendingTransaction must not build (and broadcast) another one — that would pay + the winner twice.""" + winner_id = await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + round_.payout_txid = "already-sent-txid" + session.add( + PendingTransaction( + kind="payout", + round_id=1, + current_txid="already-sent-txid", + fee_rate_sat_vb=1, + raw_tx_hex="00", + status="pending", + ) + ) + await session.commit() + + entries = [{"tx_hash": "55" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + assert not client.broadcasted + async with payout_session_factory() as session: + assert (await session.scalars(select(PendingTransaction))).all() # still just the one seeded + rows = (await session.scalars(select(PendingTransaction))).all() + assert len(rows) == 1 + assert rows[0].current_txid == "already-sent-txid" + + +async def test_reserved_payout_outpoints_excludes_utxos_claimed_by_a_stale_payout(payout_session_factory): + """A payout still "building"/"pending" for some round — most plausibly a stale + one the reconciler hasn't abandoned yet — must keep its inputs off the table for + a fresh payout attempt, or the same pool coins could be spent twice.""" + from embit import script + from embit.transaction import Transaction, TransactionInput, TransactionOutput + + reserved_txid = "66" * 32 + raw_tx = ( + Transaction( + vin=[TransactionInput(bytes.fromhex(reserved_txid), 2)], + vout=[TransactionOutput(1_000_000, script.Script.from_address(_WINNER_ADDRESS))], + ) + .serialize() + .hex() + ) + async with payout_session_factory() as session: + session.add( + PendingTransaction( + kind="payout", + round_id=99, + current_txid="stale-payout-txid", + fee_rate_sat_vb=1, + raw_tx_hex=raw_tx, + status="building", + ) + ) + await session.commit() + + reserved = await _reserved_payout_outpoints(session) + + assert reserved == {(reserved_txid, 2)} + + +# --- B-26: a "paying_out" round must retry its payout automatically --------------- + + +async def test_trigger_payout_logs_a_failure_when_not_connected(payout_session_factory): + """Before B-26, this early return logged nothing beyond a log line — invisible + in /admin and unusable as a signal for an automatic retry.""" + await _seed_paying_out_round(payout_session_factory) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client=None)) + + await scheduler._trigger_payout(1) + + async with payout_session_factory() as session: + entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).all() + assert len(entries) == 1 + assert entries[0].payload_json.count("electrum client not connected") == 1 + + +async def test_trigger_payout_logs_a_failure_when_fee_address_missing(payout_session_factory): + winner_id = await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + config = (await session.scalars(select(RoundConfig))).one() + config.fee_address = "" + await session.commit() + + entries = [{"tx_hash": "77" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(FakePayoutClient(entries))) + + await scheduler._trigger_payout(1) + + async with payout_session_factory() as session: + entry = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "payout_failed"))).one() + assert "no fee_address configured" in entry.payload_json + assert entry.user_id == winner_id + + +async def test_tick_retries_a_stuck_paying_out_round_with_no_recent_failure(payout_session_factory): + """The scenario B-26 exists for: a round stuck in "paying_out" (a prior failure, + or a process restart mid-payout) with no non-terminal PendingTransaction. A + fresh tick must retry rather than leaving it wedged forever.""" + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "88" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._tick() + + assert client.broadcasted + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + assert round_.payout_txid is not None + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.status == "pending" + + +async def test_tick_throttles_retry_after_a_recent_payout_failure(payout_session_factory): + """A payout that just failed must not be retried on the very next tick, or a + persistently-broken payout (e.g. no fee_address) would spam a retry — and a + fresh payout_failed audit entry — every _TICK_INTERVAL_SECONDS.""" + await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + session.add( + AuditLog( + event_type="payout_failed", + payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}', + round_id=1, + created_at=datetime.now(timezone.utc), + ) + ) + await session.commit() + + entries = [{"tx_hash": "99" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._tick() + + assert not client.broadcasted + async with payout_session_factory() as session: + assert (await session.scalars(select(PendingTransaction))).all() == [] + + +async def test_tick_retries_once_the_throttle_window_has_elapsed(payout_session_factory): + await _seed_paying_out_round(payout_session_factory) + async with payout_session_factory() as session: + session.add( + AuditLog( + event_type="payout_failed", + payload_json='{"round_id": 1, "reason": "insufficient pool UTXOs"}', + round_id=1, + created_at=datetime.now(timezone.utc) - timedelta(seconds=120), + ) + ) + await session.commit() + + entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS + 100_000}] + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._tick() + + assert client.broadcasted + async with payout_session_factory() as session: + pending = (await session.scalars(select(PendingTransaction))).one() + assert pending.status == "pending" + + +# --- B-28: the draw must not seed itself from an uncorroborated header ----------- + + +class CorroboratingListener: + """A fake listener whose tip advances the moment a corroboration attempt + fails, simulating a further block arriving — lets tests drive + _wait_for_next_block's retry loop deterministically without real sleeps.""" + + def __init__(self, *, responses: dict[int, bool], advance_to: dict[int, tuple[int, str]] | None = None): + self.tip_height, self.tip_header_hex = next(iter(responses)), "aa" + self._responses = dict(responses) + self._advance_to = advance_to or {} + self.corroboration_calls: list[int] = [] + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + self.corroboration_calls.append(height) + result = self._responses[height] + if not result and height in self._advance_to: + self.tip_height, self.tip_header_hex = self._advance_to[height] + return result + + +async def test_wait_for_next_block_accepts_an_immediately_corroborated_block(session_factory): + listener = CorroboratingListener(responses={101: True}) + scheduler = RoundScheduler(session_factory, listener) + + height, block_hash = await scheduler._wait_for_next_block( + round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc) + ) + + assert height == 101 + assert listener.corroboration_calls == [101] + + +async def test_wait_for_next_block_retries_after_a_failed_corroboration(session_factory): + """B-28: an uncorroborated header must never be used — the wait keeps going + until a later block's header *is* corroborated, logging why each time.""" + listener = CorroboratingListener( + responses={101: False, 102: True}, advance_to={101: (102, "bb")} + ) + scheduler = RoundScheduler(session_factory, listener) + + height, block_hash = await scheduler._wait_for_next_block( + round_id=1, tip_at_close=100, waiting_since=datetime.now(timezone.utc) + ) + + assert height == 102 + assert listener.corroboration_calls == [101, 102] + + async with session_factory() as session: + events = [e.event_type for e in (await session.scalars(select(AuditLog))).all()] + assert events == ["draw_header_corroboration_failed"] + + +# --- B-36: a stalled draw must be visible, not a silent frozen wait -------------- + + +class StallingListener: + """A tip that never advances until the test decides it should — used to drive + _wait_for_next_block's stall-detection past _DRAW_STALL_THRESHOLD_SECONDS + without a real 6-minute wait.""" + + def __init__(self): + self.tip_height = 100 + self.tip_header_hex = None + + async def corroborate_header(self, height: int, expected_hash: str) -> bool: + return True + + +async def test_wait_for_next_block_logs_a_stall_audit_entry_past_the_threshold(session_factory, monkeypatch): + import app.rounds.scheduler as scheduler_module + + listener = StallingListener() + scheduler = RoundScheduler(session_factory, listener) + start = datetime.now(timezone.utc) + + class _FakeClock: + now = start + + def fake_now(tz=None): + return _FakeClock.now + + async def fake_sleep(seconds: float) -> None: + _FakeClock.now += timedelta(seconds=seconds) + # Past the stall threshold, but before it would repeat: unblock the wait + # by making a (corroborated) block appear, so the test terminates. + if _FakeClock.now >= start + timedelta(seconds=scheduler_module._DRAW_STALL_THRESHOLD_SECONDS + 30): + listener.tip_height = 101 + listener.tip_header_hex = "aa" + + monkeypatch.setattr(scheduler_module, "datetime", type("_D", (), {"now": staticmethod(fake_now)})) + monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep) + + height, block_hash = await scheduler._wait_for_next_block(round_id=1, tip_at_close=100, waiting_since=start) + + assert height == 101 + + async with session_factory() as session: + entries = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "draw_stalled"))).all() + assert len(entries) == 1 + assert entries[0].round_id == 1 diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index 67291a5..fa2b073 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -9,8 +9,21 @@ def test_password_hash_roundtrip(): def test_jwt_roundtrip(monkeypatch): monkeypatch.setattr(security.settings, "jwt_secret", "test-secret") - token = security.create_access_token(user_id=42) - assert security.decode_access_token(token) == 42 + token = security.create_access_token(user_id=42, token_version=3) + assert security.decode_access_token(token) == (42, 3) + + +def test_jwt_decode_defaults_token_version_for_tokens_issued_before_it_existed(monkeypatch): + """B-34: a token minted before the "tv" claim existed has no such key at + all. It must still decode — as token_version 0, matching a freshly + migrated user's starting value — rather than raising or being treated as + permanently stale.""" + import jwt as pyjwt + + monkeypatch.setattr(security.settings, "jwt_secret", "test-secret") + payload = {"sub": "42"} + token = pyjwt.encode(payload, "test-secret", algorithm=security.settings.jwt_algorithm) + assert security.decode_access_token(token) == (42, 0) def test_verify_password_returns_false_for_an_unparseable_hash(): diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index 994469d..03cb8e1 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -81,7 +81,8 @@ async def test_change_password_updates_login(client): headers=headers, json={"current_password": "original-password", "new_password": "brand-new-password"}, ) - assert resp.status_code == 204 + assert resp.status_code == 200 + assert resp.json()["access_token"] resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) assert resp.status_code == 401 @@ -90,6 +91,36 @@ async def test_change_password_updates_login(client): assert resp.status_code == 200 +async def test_change_password_invalidates_the_old_token_but_not_the_new_one(client): + """B-34: neither self-service change-password nor the admin reset used to + invalidate already-issued JWTs, so a stolen token (or an attacker who + already had the old password) stayed logged in until the token's natural + 24h expiry — even past a password change meant to lock them out.""" + old_token = await _register(client) + old_headers = {"Authorization": f"Bearer {old_token}"} + + resp = await client.post( + "/users/me/change-password", + headers=old_headers, + json={"current_password": "original-password", "new_password": "brand-new-password"}, + ) + assert resp.status_code == 200 + new_token = resp.json()["access_token"] + assert new_token != old_token + + # The old token (what an attacker holding the old password would still + # have) is now rejected... + resp = await client.get("/users/me", headers=old_headers) + assert resp.status_code == 401 + assert resp.json()["detail"]["code"] == "session_expired" + + # ...but the freshly issued one keeps this same session working, so the + # user who just changed their own password isn't logged out too. + new_headers = {"Authorization": f"Bearer {new_token}"} + resp = await client.get("/users/me", headers=new_headers) + assert resp.status_code == 200 + + async def test_change_password_rejects_too_short(client): token = await _register(client) headers = {"Authorization": f"Bearer {token}"} @@ -131,3 +162,17 @@ async def test_register_rejects_weak_credentials(client, payload): async def test_register_accepts_valid_credentials(client): resp = await client.post("/auth/register", json={"username": "goodname", "password": "longenough1"}) assert resp.status_code == 201 + + +async def test_me_created_at_is_utc_stamped(client): + """B-35: SQLite/aiosqlite returns DateTime columns as naive, even though every + value written is UTC (app.db.models.utcnow). A bare .isoformat() on that naive + value has no "Z"/offset, and JavaScript's `new Date()` then parses it as local + time instead of UTC.""" + token = await _register(client) + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.get("/users/me", headers=headers) + assert resp.status_code == 200 + created_at = resp.json()["created_at"] + assert created_at.endswith("+00:00") or created_at.endswith("Z") diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index e882cc2..8691a5c 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -2,9 +2,11 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from app.bets.service import place_bet from app.config import settings from app.db.base import Base from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal +from app.rounds.events import broadcaster from app.wallet.hd import derive_user_address from app.withdrawals.service import WithdrawalError, request_withdrawal @@ -99,6 +101,35 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory): await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS) +async def test_withdrawal_distinguishes_pending_from_truly_insufficient_balance(session_factory): + """B-37: right after a bet, cached_balance_sats is ~0 because the whole funding + UTXO was spent as input and the change hasn't confirmed yet — but the UI shows + the pending-inclusive balance (compute_pending_balance), which does cover a + withdrawal of this size. The error must say "not confirmed yet", not flatly + "insufficient balance", or it contradicts what the user is looking at.""" + user_id = await _make_funded_user(session_factory, 4, 3_000_000_000) + bet_client = FakeElectrumClient() + + async with session_factory() as session: + user = await session.get(User, user_id) + await place_bet(session, bet_client, user) + + async with session_factory() as session: + user = await session.get(User, user_id) + assert user.cached_balance_sats == 0 # the whole funding UTXO was spent as input + + withdraw_client = FakeElectrumClient() + with pytest.raises(WithdrawalError) as exc_info: + # Above the withdrawal minimum (BET_AMOUNT_SATS) and covered by the + # unconfirmed change (~1_999_800_000 sats), but not by the (zero) + # confirmed balance. + await request_withdrawal(session, withdraw_client, user, EXTERNAL_ADDRESS, 1_500_000_000) + + assert exc_info.value.code == "balance_pending_confirmation" + assert exc_info.value.params["pending_sats"] > 0 + assert not withdraw_client.broadcasted + + @pytest.mark.parametrize( "address", [ @@ -141,6 +172,31 @@ async def test_withdrawal_to_own_address_is_rejected(session_factory): assert (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one().spent_txid is None +async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49 + """The released UTXOs are spendable again and the balance changed back, so the + rollback must nudge the dashboard to refetch instead of leaving it stale until + its next poll.""" + user_id = await _make_funded_user(session_factory, 10, 3_000_000_000) + + class RejectingClient: + async def broadcast(self, raw_tx_hex: str) -> str: + raise RuntimeError("min relay fee not met") + + queue = broadcaster.subscribe() + try: + while not queue.empty(): + queue.get_nowait() + + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError, match="refused"): + await request_withdrawal(session, RejectingClient(), user, derive_user_address(98), 1_000_000_000) + + assert not queue.empty() + finally: + broadcaster.unsubscribe(queue) + + async def test_failed_broadcast_marks_the_withdrawal_failed_and_frees_the_coins(session_factory): """B-07/B-08: the Withdrawal row is kept (unlike a bet) so the user can see the instruction didn't go through, but the coins must come back."""