Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)

A user could bet and then double-spend the 1-conference deposit that funded the bet with a
private chain: the bet left the chain but its participant stayed `confirmed`, and the payout
failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs
were also credited at 1 confirmation (maturity is 120).

Deposits
- `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the
  threshold for a deposit; an output of a platform transaction (any txid of a
  pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120,
  recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached).
  `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no
  session is open across the lookup.
- The quorum must also place the coin deep enough on our own tip (ELC-17).
- Confirmed outputs short of their threshold are pending deposits (memory only, replaced at
  every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal
  message), never spendable. No scripthash notification announces a maturing deposit, so each
  new block refreshes only the users that have one, one task per user (DEP-09 now says so).

Payout
- `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other
  servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a
  mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints
  (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool
  verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one
  `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same
  transaction, and the 70/30 split is computed on what is left.
- Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93,
  with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with
  `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears
  the mark and the next attempt verifies again. /admin/rounds returns it.
- Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server
  `listunspent` choice, since that address holds no single round's bets.

Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every
participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to
repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says
"1 confirmation" (7 languages, same keys); guides updated.

Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0
now set a tip past every threshold; the payout fixtures seed confirmed participants (the new
source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the
verification, and test_round_pool_address asserts the quorum is asked about the round's
address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching
or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and
disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with
the split recomputed, winner gone held and paused, held payouts untouched until the resume,
re-verification after it, no quorum, a payout with 1 and with 400 participants against
reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored
coinbase, no height bound, no verification and no winner hold each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-16 14:25:41 +02:00
1 parent daa85cf4f0
commit 54dbffb552
42 files changed
+1360 -270

No files matched your search

-16
View File
@@ -100,22 +100,6 @@ decisions, see `docs/limiti-noti.md`):
- **Contain reorgs instead of pretending they don't happen:** detect them, pause
on deep ones, and pay out only what is verifiably on chain.
## Critical
### B-82 — A deposit reorged out after a bet leaves the round unpayable
Deposits are credited at 1 confirmation. A user can bet, then (if they lost)
double-spend the deposit the bet spent with a private chain: the bet disappears but
its participant stays `confirmed` and counted, and the payout fails "insufficient
pool UTXOs" forever with every other player's money stuck. Immature coinbase outputs
are also credited at 1 confirmation (maturity is 120).
Fix: deposits credited at `deposit_confirmations` (round_config, default 6),
coinbase outputs at 120. Before building a payout the round address's UTXOs are
verified with the quorum; participants whose bet outpoint is gone are marked
`reorged_out` and the 70/30 split is computed on what is actually there. If the
winner's own bet is gone the payout is held (`payout_blocked`) and the lottery pauses.
## High
### B-87 — The RBF bump can leave a dust change output
+9 -9
View File
@@ -8,9 +8,9 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 637 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
All 10 stages of the original build order are code-complete and unit-tested — 670 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
Verified on mainnet with real money: registration + address derivation, deposit crediting (1-conf), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
Two full-codebase audits — 2026-07-26 (24 findings, 5 critical) and 2026-07-27 (25 more, B-25 … B-49) — are **all fixed** as of 2026-07-27, each with its own regression test. A third audit (2026-08-03, B-52 … B-72) and a fourth, the 2026-09-28 specification review (B-73 … B-98: draw manipulation, ambiguous broadcasts, single-txid tracking, single-server abandonment, the 64 KiB Electrum reply limit, unbumpable payouts, 1-conf deposit reorgs, ops hazards), live in the tracked [BUGS.md](BUGS.md), which lists only what is still **open** (B-71 … B-98 at the time of writing). The spec in `docs/` already describes the *target* behaviour for all of them — every affected requirement is marked `*Stato:* da implementare (B-nn)` with an `*Oggi:*` line saying what the code does now, so read both before assuming a requirement is implemented; the design decisions and the implementation order are in [docs/piano-correzioni.md](docs/piano-correzioni.md); the full history of every B-nn, open and closed, is in [docs/storico-bug.md](docs/storico-bug.md). Closed findings leave the file, so the ~276 `B-nn` markers left in comments across the code are pointers into git history (`git log --all --grep 'B-nn'` finds the commit that fixed one, and `git show f1a1145:BUGS.md`-style the file as it stood). A closed list is not the same as no bugs: the suite is unit-only (`tests/integration/` is empty), and withdrawal and the RBF bump have never been live-broadcast. "Known gaps" at the end of this file is for limitations accepted **by design** instead. A new finding gets the next B-nn, in its own commit with its own regression test.
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
python -m pytest # all 637 tests
python -m pytest # all 670 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
```
@@ -107,7 +107,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
| 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` |
| Confirmations | **1** for every platform-built tx (confirmation poller) and for the outputs it creates (bet/withdrawal change, payouts); **`deposit_confirmations` (6)** for a deposit, **120** for a coinbase output (B-82) | tx confirmation hardcoded in `tx/confirmation.py`; deposit threshold in `round_config` (`/admin`), coinbase maturity hardcoded in `deposits/service.py` |
| Max inputs per *user* tx (bet, withdrawal) | 50 (`MAX_TX_INPUTS`, B-48) — over it the build fails with `too_many_inputs`, it never spends more | hardcoded in `wallet/psbt_builder.py` |
| Max inputs per *payout* | 500 (`MAX_PAYOUT_TX_INPUTS`, B-52) — the pool holds one UTXO per bet, so reusing the user cap made any round past ~50 players unpayable | hardcoded in `wallet/psbt_builder.py` |
| Max participants per round | 400 (`MAX_PARTICIPANTS_PER_ROUND`, B-52) — the 401st bet is refused with `round_full` *before* any money moves, so "a round can always be paid out" is an invariant rather than something discovered at payout time | hardcoded in `wallet/psbt_builder.py`, enforced in `bets/service.py` |
@@ -116,7 +116,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW
**Round cooldown** (`round_cooldown_seconds`, not in the original design): gap after a round closes before the next opens, so players can see the outcome.
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx` so far, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
**Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx`, `reorg`, `payout_blocked`, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is.
**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress).
@@ -159,7 +159,7 @@ One connection serves everything — deposit credits, broadcasts, confirmations,
- **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`. A server that keeps answering pings but stopped following the chain is caught too (B-86): at every ping, if no header has been *accepted* for 360s (`_STALL_SECONDS`), `_check_not_stalled` asks the other servers for their tip and ends the session with `ServerStalledError` if any is higher, so `run()` rotates; none higher means a quiet chain and the session goes on. Without it the listener sat on queues nobody would ever fill while `listener.client` still looked alive.
- **Headers are validated, not trusted** (`_apply_header`, async since B-74): the tip never regresses, a header must meet the difficulty target it claims *and* that target must be no easier than `draw.POW_LIMIT` (PLM's powLimit; `tests/unit/conftest.py` swaps in a regtest limit so tests can mine headers, and the tests of the limit itself put `MAINNET_POW_LIMIT` back), and every advance must be linked to the tip: a single block by its `prev_hash`, a jump by downloading the headers in between (`blockchain.block.headers`, ≤2016 per request) from the active server and checking each. Failure raises `HeaderValidationError`, which ends the session like a dropped connection and rotates away — that header is the draw's only entropy, so a fabricated one picks the winner. The listener keeps the last 10 headers (`_window`, built from the first header plus its 9 predecessors); a header that does not join the tip is a reorg, walked back to its fork point inside the window and followed (`_follow_reorg`), a fork older than the window ends the session. A followed reorg writes `chain_reorg_detected` *before* the window moves (`_record_reorg`, B-85), and a depth of 2 or more pauses the lottery with `paused_reason = 'reorg'` in the same transaction; a header at the tip's own height with a different hash is a reorg too, not ignored. One more header is refused *without* ending the session (B-64): one carrying no `hex` at all (nothing to validate or draw from, and applying the height alone would break the `tip_height`/`tip_header_hex` pairing). `_run_once` separately refuses to publish the client while *no* tip is known, so the ignore-don't-kill choice can't reopen B-63.
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. The one decision still on the active server's word is the pool-coin listing for a payout (B-82). It needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
- **A quorum corroborates every money-moving decision** (`_corroborate_majority`, 10s per server, asking only the *other* endpoints — never the active one, which is what a MITM controls): `corroborate_header` before a block seeds the draw (B-28), `corroborate_utxo_spent` before a UTXO missing from one `listunspent` is written off as externally spent (B-29), and `corroborate_utxo_credit` before a new outpoint credits a balance — same outpoint, same amount, confirmed (B-59). Balances move in both directions, so both directions need the same quorum — and since B-83 that includes `corroborate_utxo_reinstatement` before a UTXO written off as externally spent is put back (same outpoint, amount, confirmed; `deposits/service.py:find_reinstate_candidates` only reads, `reinstate_reappeared_utxos` writes what the quorum agreed on), and `confirmation_proven` before a tx is marked confirmed: `tx/confirmation.py:poll_once` takes the height the active server reports only as a *claim*, and the row confirms when a strict majority of at least 2 of the *other* servers return a `get_merkle` proof that recomputes to the merkle root of the block's header (`electrum/merkle.py`; the header is the tip we hold, or for an older block the one the quorum agrees on and that meets its own target). With no `network` nothing confirms. Since B-82 the pool-coin check before a payout is on the quorum too (`verify_pool_outpoints`, below). A quorum needs **at least 2 answers** and a strict majority of those that answered (B-84): fewer → returns **False**, since one voice decides nothing independently and an unreachable network proves nothing. Only `ALLOW_NO_QUORUM=true` keeps the old reduced rule (no other server → True, otherwise a majority of whoever answers). A failed credit corroboration only *delays*: `find_new_credit_candidates` re-offers the outpoint on the next refresh or `DepositReconciler` sweep.
On reconnect `_subscribe_all_users` runs as its own task with bounded concurrency (`_RESUBSCRIBE_CONCURRENCY` = 20) rather than inline and serially — otherwise a large user base froze `tip_height`, and with it an in-flight draw, for the whole sweep (B-31); one user's failure is logged and skipped. `address_for_new_user` (called right after registration) is best-effort by design: on failure that address stays unsubscribed until the next reconnect or `DepositReconciler` sweep.
@@ -169,7 +169,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi
**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** and only once the other servers corroborate the outpoint and its amount (B-59), with the 1-conf reorg risk knowingly accepted and no rollback logic. `deposits/service.py` also detects UTXOs that vanished (spent outside the platform — corroborated per B-29 first) and *reinstates* ones that reappear.
**DEP** — the listener subscribes to the user's scripthash; balance is credited once the output reaches its threshold (B-82, DEP-03: **1** if a transaction of the platform created it — any txid of a `pending_transactions` row —, **120** for a coinbase, recognised from the raw tx that must hash to the txid, `ElectrumListener.is_coinbase`, and `round_config.deposit_confirmations`, default 6, for everything else) **and** only once the other servers corroborate the outpoint, its amount and a height that gives it that many confirmations on our tip (B-59, ELC-17). The deposit is the one input a user can double-spend, hence the 6. Outputs short of their threshold are *pending deposits* (`ElectrumListener._pending_deposits`, memory only): shown in `pending_balance_sats` (amber), never spendable, and since no scripthash notification announces a maturing deposit each new block refreshes only the users that have one (`_refresh_waiting_deposits`). A deposit that vanishes later takes the spent-outside path; there is no other 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**, and **at most `MAX_PARTICIPANTS_PER_ROUND` (400) players per round** — past that the bet is refused with `round_full` and the player waits for the next round (B-52: the payout must spend one pool UTXO per bet, so a round is only ever allowed to grow to what a single payout tx can drain). PSBT user-address → pool-address, always with a **change output back to the same user address** of at least `DUST_LIMIT_SATS` — a user's balance must never exactly equal the bet, and since B-62 that's enforced (`balance_leaves_no_change`) rather than assumed. Fee ~1 sat/vB, **deducted from the bet amount**. No confirmation within the timeout → RBF bump and rebroadcast.
@@ -177,7 +177,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi
- *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from.
- *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms).
- *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it, and since B-81 `_tick` calls it again for any round found in `drawing` — after an exception or a restart — so it is idempotent: a round no longer in `drawing`, or already holding a winner, is left alone); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`.
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern.
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern. **Verified before it is built** (B-82, RND-22, RND-39, `_trigger_payout` → `_verify_round_pool` → `ElectrumListener.verify_pool_outpoints`): a round with its own pool address spends exactly its confirmed participants' bet outputs (`bet_txid:bet_vout`), never whatever else sits at the address, and first asks the *other* servers whether each is still there — a strict majority of at least 2 answers; a mempool-only coin or a tie concludes nothing (`payout_failed`, `pool verification unavailable`, retried). A participant whose bet is gone becomes `reorged_out`, `participant_reorged_out` is audit-logged, `pool_amount_sats` is rewritten (only ever downward) in the same transaction and the 70/30 split is computed on what is left. If the **winner's own** bet is gone nothing is built: `payout_blocked_at` is set, `payout_blocked` is logged and the lottery is paused with `paused_reason = 'payout_blocked'`; the winner's row stays `confirmed` so that after `POST /admin/resume` (which clears the mark) the verification really runs again. A round opened before B-80 (pool = the shared reserve address) keeps the old active-server `listunspent` choice.
- *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`.
@@ -251,7 +251,7 @@ Explicit design choices, not derivable from any single file — respect them:
- 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.
- **1 confirmation** for every transaction the platform builds (bets, withdrawals, payouts, and the outputs they create). Deposits (`deposit_confirmations`, 6) and coinbase outputs (120) are the explicit exceptions of B-82 — don't add other thresholds without an explicit decision.
- The draw algorithm is a **replaceable component**, not the final design — don't architect around its current form.
- `GET /admin/users/{id}/privkey` exporting a raw WIF is **intentional**, not a vulnerability: the server already holds the master key, so this only exposes via API what an operator could script anyway. Every access writes `admin_privkey_accessed` — don't remove that logging.
- **Usernames are case-insensitive** (B-57): one namespace, enforced by a unique index on `lower(username)` (`app/db/models.py`) and matched with `func.lower(...)` on both register and login. The name is still *stored* as typed — that's what `/admin` and the audit log show. The migration refuses to run if two existing accounts differ only by case, rather than guessing which one to rename: both may hold funds.
+1 -1
View File
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
637 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
670 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
+9 -1
View File
@@ -3,7 +3,7 @@ import secrets
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import http_error
@@ -47,6 +47,7 @@ _CONFIG_FIELDS = (
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"deposit_confirmations", # B-82 (DAT-62)
"draw_confirmations", # B-73 (DAT-62)
)
@@ -59,6 +60,7 @@ class RoundConfigResponse(BaseModel):
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
deposit_confirmations: int # B-82 (DAT-62)
draw_confirmations: int # B-73 (DAT-62)
paused: bool
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
@@ -81,6 +83,7 @@ class RoundConfigUpdate(BaseModel):
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
deposit_confirmations: int | None = Field(default=None, ge=1, le=100) # B-82 (DAT-62)
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
@field_validator("fee_address")
@@ -186,6 +189,9 @@ async def resume_lottery(session: AsyncSession = Depends(get_session)) -> RoundC
lifted = config.paused_reason if config.paused else None
config.paused = False
config.paused_reason = None
# B-82 (DAT-68, API-32): a payout held because the winner's bet left the chain is released
# for a new attempt, which verifies the coins again, in the same transaction as the resume.
await session.execute(update(Round).where(Round.payout_blocked_at.is_not(None)).values(payout_blocked_at=None))
await write_audit_log(session, "lottery_resumed", {"reason": lifted})
await session.commit()
return _config_response(config)
@@ -287,6 +293,7 @@ class AdminRoundResponse(BaseModel):
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
bump_reserve_sats: int | None # B-79 (API-36)
payout_blocked_at: str | None # B-82 (API-36, DAT-68)
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
@@ -318,6 +325,7 @@ async def list_rounds(
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
bump_reserve_sats=r.bump_reserve_sats,
payout_blocked_at=isoformat_utc(r.payout_blocked_at),
)
for r in rounds
]
+7 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,10 +31,15 @@ class MeResponse(BaseModel):
@router.get("/me", response_model=MeResponse)
async def me(
request: Request,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MeResponse:
pending_balance_sats, has_pending = await compute_pending_balance(session, user)
# B-82 (DEP-12 (b)): deposits confirmed but short of their threshold, kept in memory by
# the listener (absent in an app without one, e.g. a test of this route alone).
listener = getattr(request.app.state, "electrum_listener", None)
waiting_sats = listener.pending_deposit_sats(user.id) if listener is not None else 0
pending_balance_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
return MeResponse(
id=user.id,
username=user.username,
+6
View File
@@ -114,6 +114,9 @@ class Round(Base):
# the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump).
bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-82 (DAT-68): set when the verification before the payout finds the *winner's* bet gone
# from the chain; while it is set no payout is built. Cleared by POST /admin/resume.
payout_blocked_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
@@ -184,6 +187,9 @@ class RoundConfig(Base):
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
# applies from the next round.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
# B-82 (DAT-62, DEP-03): confirmations after which an output paying a user is credited
# as a deposit (platform transactions keep 1, coinbase outputs 120).
deposit_confirmations: Mapped[int] = mapped_column(default=6, server_default="6")
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
# Maintenance switch: when true, the round currently in progress still runs to
+85 -1
View File
@@ -1,17 +1,33 @@
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import UtxoEvent
from app.db.models import PendingTransaction, PendingTransactionTxid, RoundConfig, UtxoEvent
from app.rounds.events import broadcaster
from app.wallet.balance import recompute_balance
from app.wallet.psbt_builder import DUST_LIMIT_SATS
logger = logging.getLogger(__name__)
# B-82 (DEP-03): confirmations a coinbase output needs before it can be spent, PLM's
# COINBASE_MATURITY (palladiumcore/src/consensus/consensus.h). A coinbase paying a user's
# address is credited no earlier than this, whatever deposit_confirmations says.
COINBASE_MATURITY = 120
# Confirmations an output created by a transaction of the platform itself — the change of
# a bet or a withdrawal, a payout — needs: the platform built it, nobody else could have
# double-spent its inputs (DEP-03).
PLATFORM_TRANSACTION_CONFIRMATIONS = 1
# Default of round_config.deposit_confirmations, used only when the config row does not
# exist yet (get_round_config creates it with the same value).
DEFAULT_DEPOSIT_CONFIRMATIONS = 6
async def find_new_credit_candidates(session: AsyncSession, user_id: int, entries: list[dict]) -> list[dict]:
"""The subset of `entries` that would actually credit something: confirmed
@@ -41,6 +57,74 @@ async def find_new_credit_candidates(session: AsyncSession, user_id: int, entrie
]
@dataclass
class DepositPolicy:
"""What decides how many confirmations an incoming output needs (DEP-03), read from the
database before any network call: the configured deposit threshold and which of the
transactions involved are the platform's own."""
deposit_confirmations: int = DEFAULT_DEPOSIT_CONFIRMATIONS
platform_txids: set[str] = field(default_factory=set)
async def read_deposit_policy(session: AsyncSession, txids: set[str]) -> DepositPolicy:
"""The deposit threshold and, among `txids`, the ones created by a platform transaction:
any txid of a `pending_transactions` row, replaced RBF versions included (B-77)."""
configured = await session.scalar(select(RoundConfig.deposit_confirmations))
platform: set[str] = set()
if txids:
platform |= set(
(await session.scalars(select(PendingTransactionTxid.txid).where(PendingTransactionTxid.txid.in_(txids))))
)
platform |= set(
(await session.scalars(select(PendingTransaction.current_txid).where(PendingTransaction.current_txid.in_(txids))))
)
return DepositPolicy(
deposit_confirmations=configured if configured else DEFAULT_DEPOSIT_CONFIRMATIONS, platform_txids=platform
)
async def classify_new_deposits(
new_entries: list[dict],
tip_height: int,
policy: DepositPolicy,
is_coinbase: Callable[[str], Awaitable[bool | None]],
) -> tuple[list[tuple[dict, int]], list[dict]]:
"""Split the new, confirmed, non-dust entries of `find_new_credit_candidates` into the
ones that may now be credited — as `(entry, required_confirmations)`, the number the
quorum is then asked to confirm (ELC-17) — and the ones still **waiting** for
confirmations (B-82, DEP-03, DEP-14).
Confirmations are `tip_height - height + 1`. The threshold is 1 for an output of a
platform transaction, 120 (`COINBASE_MATURITY`) for a coinbase output, and
`deposit_confirmations` for anything else; a coinbase is only looked for once the plain
threshold is met, since below it the output waits anyway. `is_coinbase` answers from the
raw transaction (hash-checked by the caller) and may return None when it cannot tell, in
which case the output waits and is asked again at the next refresh. No database session
is involved: this runs between the read and the write phases, where the network is used.
"""
candidates: list[tuple[dict, int]] = []
waiting: list[dict] = []
for entry in new_entries:
confirmations = tip_height - entry["height"] + 1
if entry["tx_hash"] in policy.platform_txids:
required = PLATFORM_TRANSACTION_CONFIRMATIONS
else:
required = policy.deposit_confirmations
if required <= confirmations < COINBASE_MATURITY:
# Plain threshold met, maturity not: only now is it worth knowing whether
# this is a coinbase output (which would still be immature).
coinbase = await is_coinbase(entry["tx_hash"])
if coinbase is None or coinbase:
waiting.append(entry)
continue
if confirmations < required:
waiting.append(entry)
continue
candidates.append((entry, required))
return candidates, waiting
async def credit_confirmed_utxos(session: AsyncSession, user_id: int, entries: list[dict]) -> int:
"""Insert utxo_events for newly-confirmed entries from an Electrum
`listunspent` response (idempotent on txid+vout), refresh the user's cached
+145 -7
View File
@@ -12,11 +12,13 @@ from app.audit.log import write_audit_log
from app.db.base import begin_immediate
from app.db.models import User
from app.deposits.service import (
classify_new_deposits,
credit_confirmed_utxos,
find_new_credit_candidates,
find_reinstate_candidates,
find_utxos_missing_from,
mark_utxos_spent_externally,
read_deposit_policy,
reinstate_reappeared_utxos,
)
from app.config import MIN_QUORUM_RESPONSES
@@ -120,6 +122,15 @@ class ElectrumListener:
# B-86: when the last header was accepted (monotonic seconds), None until one is.
self._last_header_at: float | None = None
self.client: ElectrumClient | None = None
# B-82 (DEP-14): per user, the deposits seen at the last refresh that are confirmed
# but still short of their threshold — what the displayed balance shows as pending.
# Memory only: it moves no money and is rebuilt by the first refresh after a restart.
self._pending_deposits: dict[int, list[dict]] = {}
# txid -> is it a coinbase (DEP-03). A transaction never changes, so this is safe to
# keep; bounded so a long-lived process does not grow it without limit.
self._coinbase_cache: dict[str, bool] = {}
# Users with a refresh already running because of a new block (see _consume_headers).
self._block_refreshes: set[int] = set()
@property
def current_endpoint(self) -> ElectrumEndpoint | None:
@@ -819,7 +830,9 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, f"outpoint {txid}:{vout}")
async def corroborate_utxo_credit(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
async def corroborate_utxo_credit(
self, scripthash: str, txid: str, vout: int, value: int, required_confirmations: int | None = None
) -> bool:
"""B-59: the mirror of corroborate_utxo_spent, for money going the other
way. A candidate external spend could not reduce a balance without a
quorum, but `value` and `height` for a *credit* were taken from the single
@@ -841,8 +854,11 @@ class ElectrumListener:
only credited once the quorum agrees.
"""
# B-82 (ELC-17): the other server must also place the coin deep enough — at a height
# that gives it `required_confirmations` counted on *our* tip, not on its own.
max_height = None if required_confirmations is None else self.tip_height - required_confirmations + 1
return await self._corroborate_unspent_confirmed(
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}"
scripthash, txid, vout, value, f"credit of {value} sats at {txid}:{vout}", max_height=max_height
)
async def corroborate_utxo_reinstatement(self, scripthash: str, txid: str, vout: int, value: int) -> bool:
@@ -856,10 +872,11 @@ class ElectrumListener:
)
async def _corroborate_unspent_confirmed(
self, scripthash: str, txid: str, vout: int, value: int, description: str
self, scripthash: str, txid: str, vout: int, value: int, description: str, max_height: int | None = None
) -> bool:
"""Do the other servers list this outpoint as unspent, for the same amount, and
confirmed (height > 0)? Shared by credits (B-59) and reinstatements (B-83)."""
confirmed (height > 0, and no higher than `max_height` when one is given)? Shared by
credits (B-59, B-82) and reinstatements (B-83)."""
async def _ask(endpoint: ElectrumEndpoint) -> bool | None:
client = self._client_factory(endpoint)
@@ -873,6 +890,7 @@ class ElectrumListener:
and e.get("tx_pos") == vout
and e.get("value") == value
and (e.get("height") or 0) > 0
and (max_height is None or (e.get("height") or 0) <= max_height)
for e in entries
)
except Exception:
@@ -882,6 +900,59 @@ class ElectrumListener:
return await self._corroborate_majority(_ask, lambda agrees: agrees, description)
async def verify_pool_outpoints(
self, scripthash: str, outpoints: list[tuple[str, int, int]]
) -> dict[tuple[str, int], bool] | None:
"""Which of a round's bet outputs (`(txid, vout, amount)`) are really there, at the
round's pool address, according to the *other* servers (B-82, ELC-27, RND-39).
Every other server lists the address' unspent coins; each answer says, per outpoint,
*present* (listed, same amount, confirmed), *absent* (not listed, or listed with
another amount) or neither (listed but unconfirmed — a block that left the chain
may well come back, which is no reason to throw a bet out). An outpoint is present
or absent when a strict majority of the answers say so. The result maps every
outpoint to True (present) or False (absent); it is None — nothing concluded — when
fewer than the quorum minimum (ELC-14) of servers answered or an outpoint has no
majority either way, so a participant is only ever excluded on the quorum's word and
the caller simply tries again later. With ALLOW_NO_QUORUM=true and no other server
configured the active server answers instead (development only)."""
async def _ask(client: ElectrumClient) -> dict[tuple[str, int], tuple[int, int]] | None:
entries = await client.listunspent(scripthash)
return {(e["tx_hash"], e["tx_pos"]): (e["value"], e.get("height") or 0) for e in entries}
answers = [a for a in await self._ask_other_servers(_ask) if a is not None]
if not self._other_endpoints() and self._allow_no_quorum:
answers = [a for a in [await self._ask_active_server(_ask)] if a is not None]
elif len(answers) < max(self._minimum_other_answers(), 1):
logger.warning(
"could not verify the pool coins at %s: %s answer(s) from the other servers, %s needed",
scripthash,
len(answers),
max(self._minimum_other_answers(), 1),
)
return None
if not answers:
return None
result: dict[tuple[str, int], bool] = {}
for txid, vout, amount in outpoints:
present = absent = 0
for answer in answers:
listed = answer.get((txid, vout))
if listed is None or listed[0] != amount:
absent += 1
elif listed[1] > 0:
present += 1
if present * 2 > len(answers):
result[(txid, vout)] = True
elif absent * 2 > len(answers):
result[(txid, vout)] = False
else:
logger.warning("the servers do not agree whether %s:%s is still on the chain", txid, vout)
return None
return result
# --- Confirmations proven by merkle branch (B-83, TX-22, ELC-26) ---------------------
async def _header_at(self, height: int) -> str | None:
@@ -942,6 +1013,63 @@ class ElectrumListener:
# (rounds/scheduler.py:_wait_for_next_block) — nudge dashboards to
# refetch instead of waiting for their next poll.
broadcaster.publish()
self._refresh_waiting_deposits()
def _refresh_waiting_deposits(self) -> None:
"""A deposit that is waiting for confirmations (DEP-14) does not change its address'
status when the next block arrives — the history is the same, only the tip moved — so
no scripthash notification will ever announce that it has matured. Refresh the users
that have one at each new block, one task per user at a time, instead of leaving it to
the 300s sweep (B-82, DEP-09)."""
if self.client is None:
return
scripthash_of = {user_id: scripthash for scripthash, user_id in self._scripthash_to_user.items()}
for user_id, waiting in list(self._pending_deposits.items()):
scripthash = scripthash_of.get(user_id)
if not waiting or scripthash is None or user_id in self._block_refreshes:
continue
self._block_refreshes.add(user_id)
task = asyncio.create_task(self._refresh_after_block(user_id, scripthash))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
async def _refresh_after_block(self, user_id: int, scripthash: str) -> None:
try:
await self.refresh_user(user_id, scripthash)
except Exception:
logger.exception("refreshing user_id=%s after a new block failed", user_id)
finally:
self._block_refreshes.discard(user_id)
def pending_deposit_sats(self, user_id: int) -> int:
"""Total of the user's deposits that are confirmed but short of their threshold
(DEP-12 (b), DEP-14); 0 when there are none or nothing has been refreshed yet."""
return sum(entry["value"] for entry in self._pending_deposits.get(user_id, []))
async def is_coinbase(self, txid: str) -> bool | None:
"""Is `txid` a coinbase transaction (DEP-03)? Read from the raw transaction, which is
accepted only if it hashes to `txid` — so the server cannot misdescribe it — and
whose single input has the null outpoint. None when it could not be fetched or did
not match; the caller then simply asks again at the next refresh."""
cached = self._coinbase_cache.get(txid)
if cached is not None:
return cached
if self.client is None:
return None
try:
raw = await self.client.get_transaction(txid, verbose=False)
tx = Transaction.parse(bytes.fromhex(raw))
except Exception:
logger.warning("could not read transaction %s to tell whether it is a coinbase", txid)
return None
if tx.txid().hex() != txid:
logger.warning("transaction %s: the server returned one that hashes to %s", txid, tx.txid().hex())
return None
result = len(tx.vin) == 1 and tx.vin[0].txid == bytes(32) and tx.vin[0].vout == 0xFFFFFFFF
if len(self._coinbase_cache) >= 10_000:
self._coinbase_cache.clear()
self._coinbase_cache[txid] = result
return result
async def _consume_scripthash(self, queue: asyncio.Queue) -> None:
while True:
@@ -960,7 +1088,8 @@ class ElectrumListener:
entries = await self.client.listunspent(scripthash)
async with self._session_factory() as session:
credit_candidates = await find_new_credit_candidates(session, user_id, entries)
new_entries = await find_new_credit_candidates(session, user_id, entries)
policy = await read_deposit_policy(session, {entry["tx_hash"] for entry in new_entries})
# B-83 (DEP-02): candidates only — nothing is written before the quorum agrees.
reinstate_candidates = await find_reinstate_candidates(session, user_id, entries)
candidates = [
@@ -968,11 +1097,20 @@ class ElectrumListener:
for row in await find_utxos_missing_from(session, user_id, entries)
]
# B-82 (DEP-03, DEP-14): which of the new outputs have reached their threshold; the
# others are remembered as pending deposits (memory only) and shown as such.
credit_candidates, waiting_deposits = await classify_new_deposits(
new_entries, self.tip_height, policy, self.is_coinbase
)
self._pending_deposits[user_id] = waiting_deposits
if not waiting_deposits:
self._pending_deposits.pop(user_id, None)
corroborated_credits = [
entry
for entry in credit_candidates
for entry, required in credit_candidates
if await self.corroborate_utxo_credit(
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"]
scripthash, entry["tx_hash"], entry["tx_pos"], entry["value"], required_confirmations=required
)
]
+141 -8
View File
@@ -22,6 +22,7 @@ from app.rounds.draw import (
server_seed_commitment,
)
from app.rounds.events import broadcaster
from app.rounds.pause import pause_automatically
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
@@ -437,6 +438,8 @@ class RoundScheduler:
either way it's due immediately.
"""
async with self._session_factory() as session:
if await session.scalar(select(Round.payout_blocked_at).where(Round.id == round_id)) is not None:
return # B-82 (DAT-68): held until an operator resumes the lottery
last_failure_at = await session.scalar(
select(AuditLog.created_at)
.where(AuditLog.event_type == "payout_failed", AuditLog.round_id == round_id)
@@ -477,6 +480,11 @@ class RoundScheduler:
logger.warning("round %s payout deferred: database busy", round_id)
return
round_ = await session.get(Round, round_id)
if round_.payout_blocked_at is not None:
# B-82 (DAT-68): held since the winner's own bet left the chain. Nothing is built
# until an operator resumes the lottery, which clears the mark (and the next
# attempt verifies the coins again).
return
already_in_flight = await session.scalar(
select(PendingTransaction).where(
PendingTransaction.round_id == round_id,
@@ -504,6 +512,18 @@ class RoundScheduler:
winner_user_id = round_.winner_user_id
winner = await session.get(User, winner_user_id)
winner_address = winner.address if winner is not None else None
# B-82 (RND-22, RND-39): the bets the payout may spend and must first verify — the
# round's confirmed participants, each with the output that pays the pool.
bet_rows = [
(p.id, p.user_id, p.bet_txid, p.bet_vout, p.bet_amount_sats)
for p in (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
.order_by(RoundParticipant.draw_position)
)
).all()
]
await session.commit() # get_round_config may have created the row
if not fee_address:
@@ -517,8 +537,10 @@ class RoundScheduler:
await self._log_payout_failure(round_id, winner_user_id, "winner user not found")
return
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
# A round opened before the per-round pool address (B-80) pays from the shared reserve
# address, whose coins are not the bets of one round: it keeps the old way of choosing
# them. Every other round is verified and paid from its own bets (RND-22, RND-39).
legacy_pool = round_pool_address == derive_pool_address()
# --- Phase 2: build (network read only, no DB write yet) -----------------
try:
@@ -528,12 +550,33 @@ class RoundScheduler:
pool_key = derive_round_pool_key(round_id, round_pool_address)
pool_script_obj = script.p2wpkh(pool_key.to_public())
pool_address = pool_script_obj.address(network=PLM_MAINNET)
entries = await client.listunspent(address_to_scripthash(pool_address))
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
]
if not legacy_pool:
# B-82 (RND-39): with the quorum, before anything is built — bets that left the
# chain drop out and the split is computed on what is really there.
verified = await self._verify_round_pool(
round_id, pool_address, winner_user_id, bet_rows, pool_amount_sats
)
if verified is None:
return # held, unverifiable or database busy — already recorded
bet_rows, pool_amount_sats = verified
winner_sats = winner_share(pool_amount_sats)
commission_share = pool_amount_sats - winner_sats # remainder from rounding goes to fees
if legacy_pool:
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 and (e["tx_hash"], e["tx_pos"]) not in reserved_outpoints
]
else:
# B-82 (RND-22): exactly the verified bet outputs of this round, nothing else
# that happens to sit at the address, and none already claimed by an unresolved
# payout.
utxos = [
Utxo(txid, vout, amount)
for _id, _user_id, txid, vout, amount in bet_rows
if (txid, vout) not in reserved_outpoints
]
built = build_payout_transaction(
signing_key=pool_key,
@@ -634,6 +677,96 @@ class RoundScheduler:
logger.info("round %s payout broadcast: txid=%s", round_id, built.txid)
async def _verify_round_pool(
self,
round_id: int,
pool_address: str,
winner_user_id: int | None,
bet_rows: list[tuple[int, int, str, int, int]],
pool_amount_sats: int | None,
) -> tuple[list[tuple[int, int, str, int, int]], int] | None:
"""B-82 (RND-39, DAT-67, DAT-68, ELC-27): before a payout is built, check with the quorum
that each confirmed participant's bet output is still at the round's pool address.
Returns the participants that are really there with the pool amount recomputed on them
(the same pair when nothing was missing), or None when this attempt must stop: the
quorum could not be reached (`payout_failed`, `pool verification unavailable`), the
database was busy, or the **winner's own bet is gone** — then the payout is held
(`payout_blocked`, the lottery pauses) and only an operator's resume tries again.
Participants whose bet is gone become `reorged_out` and leave the pool, one
`participant_reorged_out` row each, in one transaction with the rewritten
`pool_amount_sats` (which can only go down). The winner's own row is left `confirmed`
in the held case, precisely so that the verification repeats after the resume."""
verdicts = await self._listener.verify_pool_outpoints(
address_to_scripthash(pool_address), [(txid, vout, amount) for _i, _u, txid, vout, amount in bet_rows]
)
if verdicts is None:
logger.error("round %s payout deferred: the pool coins could not be verified with the quorum", round_id)
await self._log_payout_failure(round_id, winner_user_id, "pool verification unavailable")
return None
gone = [row for row in bet_rows if not verdicts[(row[2], row[3])]]
if not gone:
return bet_rows, pool_amount_sats
winner_gone = next((row for row in gone if row[1] == winner_user_id), None)
async with self._session_factory() as session:
try:
await begin_immediate(session) # B-88
except OperationalError:
logger.warning("round %s payout deferred: database busy while excluding reorged-out bets", round_id)
return None
round_ = await session.get(Round, round_id)
excluded = [row for row in gone if row is not winner_gone]
for participant_id, user_id, txid, vout, amount in excluded:
participant = await session.get(RoundParticipant, participant_id)
if participant is None or participant.status != "confirmed":
continue
participant.status = "reorged_out"
await session.flush()
remaining = (
await session.scalars(
select(RoundParticipant).where(
RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed"
)
)
).all()
new_pool = sum(p.bet_amount_sats for p in remaining)
if pool_amount_sats is not None:
new_pool = min(new_pool, pool_amount_sats) # DAT-67: only ever reduced
round_.pool_amount_sats = new_pool
for _participant_id, user_id, txid, vout, amount in excluded:
await write_audit_log(
session,
"participant_reorged_out",
{"txid": txid, "vout": vout, "amount_sats": amount, "pool_amount_sats": new_pool},
user_id=user_id,
round_id=round_id,
)
if winner_gone is not None:
_pid, user_id, txid, vout, amount = winner_gone
round_.payout_blocked_at = datetime.now(timezone.utc)
await write_audit_log(
session,
"payout_blocked",
{"txid": txid, "vout": vout, "amount_sats": amount},
user_id=user_id,
round_id=round_id,
)
logger.error(
"round %s payout held: the winner's bet %s:%s is no longer on the chain; lottery paused",
round_id,
txid,
vout,
)
await pause_automatically(session, "payout_blocked") # same transaction (AUD-44)
await session.commit()
broadcaster.publish()
if winner_gone is not None:
return None
gone_ids = {row[0] for row in gone}
return [row for row in bet_rows if row[0] not in gone_ids], new_pool
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. Called from every
+2
View File
@@ -85,6 +85,8 @@
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
<label for="admin-deposit-confirmations">Conferme per accreditare un deposito (1–100; le uscite coinbase ne richiedono comunque 120)</label>
<input id="admin-deposit-confirmations" inputmode="numeric" placeholder="es. 6">
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
+2
View File
@@ -151,6 +151,7 @@ async function adminLoadConfig() {
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
document.getElementById('admin-deposit-confirmations').value = data.deposit_confirmations;
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
renderMaintenanceState(data.paused);
@@ -211,6 +212,7 @@ async function adminSave() {
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
deposit_confirmations: parseInt(document.getElementById('admin-deposit-confirmations').value, 10),
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
};
+7 -7
View File
@@ -87,7 +87,7 @@ const TRANSLATIONS = {
'result.lose': 'Not a win this time.',
'deposit.balanceTitle': 'Internal balance',
'deposit.balanceHint': 'Updated after 1 network confirmation',
'deposit.balanceHint': 'Updated once the network has confirmed your deposit (a few blocks)',
'deposit.refreshBtn': 'Refresh',
'deposit.refreshAria': 'Refresh balance',
'deposit.addressTitle': 'Deposit address',
@@ -258,7 +258,7 @@ const TRANSLATIONS = {
'result.lose': 'Non hai vinto questa volta.',
'deposit.balanceTitle': 'Saldo interno',
'deposit.balanceHint': 'Aggiornato dopo 1 conferma sulla rete',
'deposit.balanceHint': 'Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)',
'deposit.refreshBtn': 'Aggiorna',
'deposit.refreshAria': 'Aggiorna saldo',
'deposit.addressTitle': 'Indirizzo di deposito',
@@ -426,7 +426,7 @@ const TRANSLATIONS = {
'result.lose': 'Esta vez no has ganado.',
'deposit.balanceTitle': 'Saldo interno',
'deposit.balanceHint': 'Actualizado tras 1 confirmación en la red',
'deposit.balanceHint': 'Se actualiza cuando la red confirma tu depósito (unos bloques)',
'deposit.refreshBtn': 'Actualizar',
'deposit.refreshAria': 'Actualizar saldo',
'deposit.addressTitle': 'Dirección de depósito',
@@ -594,7 +594,7 @@ const TRANSLATIONS = {
'result.lose': "Pas de gain cette fois-ci.",
'deposit.balanceTitle': 'Solde interne',
'deposit.balanceHint': 'Mis à jour après 1 confirmation sur le réseau',
'deposit.balanceHint': 'Mis à jour une fois le dépôt confirmé par le réseau (quelques blocs)',
'deposit.refreshBtn': 'Actualiser',
'deposit.refreshAria': 'Actualiser le solde',
'deposit.addressTitle': 'Adresse de dépôt',
@@ -762,7 +762,7 @@ const TRANSLATIONS = {
'result.lose': 'Diesmal kein Gewinn.',
'deposit.balanceTitle': 'Internes Guthaben',
'deposit.balanceHint': 'Aktualisiert nach 1 Netzwerkbestätigung',
'deposit.balanceHint': 'Aktualisiert, sobald das Netzwerk die Einzahlung bestätigt hat (einige Blöcke)',
'deposit.refreshBtn': 'Aktualisieren',
'deposit.refreshAria': 'Guthaben aktualisieren',
'deposit.addressTitle': 'Einzahlungsadresse',
@@ -930,7 +930,7 @@ const TRANSLATIONS = {
'result.lose': 'На этот раз без выигрыша.',
'deposit.balanceTitle': 'Внутренний баланс',
'deposit.balanceHint': 'Обновляется после 1 подтверждения в сети',
'deposit.balanceHint': 'Обновляется, когда сеть подтвердит депозит (несколько блоков)',
'deposit.refreshBtn': 'Обновить',
'deposit.refreshAria': 'Обновить баланс',
'deposit.addressTitle': 'Адрес для депозита',
@@ -1098,7 +1098,7 @@ const TRANSLATIONS = {
'result.lose': '这次没有中奖。',
'deposit.balanceTitle': '内部余额',
'deposit.balanceHint': '在网络确认 1 次后更新',
'deposit.balanceHint': '网络确认存款后更新(需要数个区块)',
'deposit.refreshBtn': '刷新',
'deposit.refreshAria': '刷新余额',
'deposit.addressTitle': '存款地址',
+1 -1
View File
@@ -176,7 +176,7 @@
<div class="dash-panel active" id="panel-deposit">
<div class="card">
<h2 data-i18n="deposit.balanceTitle">Saldo interno</h2>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato dopo 1 conferma sulla rete</p>
<p class="hint" data-i18n="deposit.balanceHint">Aggiornato quando la rete ha confermato il deposito (alcuni blocchi)</p>
<div class="row-between">
<div><span class="balance-value mono" id="dash-balance">—</span> <span class="balance-unit">PLM</span></div>
<button class="secondary" onclick="refreshMe()" id="refresh-btn" data-i18n-aria-label="deposit.refreshAria" aria-label="Aggiorna saldo">
+9 -2
View File
@@ -18,7 +18,9 @@ async def recompute_balance(session: AsyncSession, user_id: int) -> int:
return user.cached_balance_sats
async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[int, bool]:
async def compute_pending_balance(
session: AsyncSession, user: User, pending_deposit_sats: int = 0
) -> tuple[int, bool]:
"""Balance including the user's own change still in flight.
Placing a bet or a withdrawal spends whatever UTXOs cover the amount — often
@@ -47,6 +49,11 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
the same sats are already inside cached_balance_sats — double-counting the
change unless excluded below.
B-82 (DEP-12 (b), DEP-14): `pending_deposit_sats` is the total of the user's deposits
that are confirmed but still short of their threshold (the listener keeps them, in
memory); they are added to what is shown, and make `has_pending` true, exactly like
change in flight — without ever being spendable.
Returns (pending_inclusive_balance_sats, has_pending) — has_pending tells the
caller whether this differs from the confirmed-only balance at all.
"""
@@ -79,4 +86,4 @@ async def compute_pending_balance(session: AsyncSession, user: User) -> tuple[in
if out.script_pubkey.address(network=PLM_MAINNET) == user.address:
pending_change_sats += out.value
return user.cached_balance_sats + pending_change_sats, bool(pending)
return user.cached_balance_sats + pending_change_sats + pending_deposit_sats, bool(pending) or pending_deposit_sats > 0
+4 -1
View File
@@ -108,7 +108,10 @@ async def request_withdrawal(
# "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)
# B-82 (DEP-13): the displayed balance includes deposits still short of their
# threshold, so a withdrawal they would cover gets "wait for the confirmation".
waiting_sats = network.pending_deposit_sats(user.id) if hasattr(network, "pending_deposit_sats") else 0
pending_inclusive_sats, has_pending = await compute_pending_balance(session, user, waiting_sats)
if has_pending and pending_inclusive_sats >= amount_sats:
raise WithdrawalError(
"balance_pending_confirmation",
@@ -1,6 +1,6 @@
# ADR-06 — I depositi richiedono `deposit_confirmations` conferme (6 per default, configurabile in `round_config`), le uscite coinbase 120; ogni altra transazione ne richiede 1
- **Stato:** Accettata — implementazione da completare (B-82)
- **Stato:** Accettata
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -34,7 +34,5 @@ decisione esplicita.
è contenuto da [ADR-52](ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md) e accettato in [LIM-24]. Regole in
[registrazione-e-depositi.md](../registrazione-e-depositi.md#deposito) (B-82).
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` ·
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`
*Stato:* da implementare (B-82) · *Oggi:* ogni moneta, deposito o coinbase
compresi, si accredita a 1 conferma.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/deposits/service.py:find_new_credit_candidates` · `app/deposits/service.py:classify_new_deposits` ·
*Test:* `tests/unit/test_confirmation.py::test_unconfirmed_tx_is_left_pending`, `tests/unit/test_deposits.py::test_unconfirmed_entry_is_ignored`, `tests/unit/test_deposit_confirmations.py::test_a_deposit_is_credited_exactly_at_six_confirmations`, `tests/unit/test_deposit_confirmations.py::test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations`, `tests/unit/test_deposit_confirmations.py::test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation`
@@ -1,6 +1,6 @@
# ADR-52 — Le riorganizzazioni della catena si contengono invece di ignorarle
- **Stato:** Accettata — implementazione da completare (B-82)
- **Stato:** Accettata
- **Area:** Conferme e fiducia nella rete
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
@@ -43,6 +43,4 @@ e [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento)
([LIM-24]).
*Codice:* `app/electrum/listener.py:ElectrumListener._record_reorg` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/rounds/service.py:open_new_round_if_needed` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento non verifica le monete
del round: le prime due misure (rilevare, fermare) sono attive.
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_pool_verification.py`
+6 -11
View File
@@ -577,7 +577,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
`app/rounds/service.py:round_deadline`, `app/rounds/service.py:winner_share`,
`app/rounds/service.py:rounds_can_open` ·
*Test:* `tests/unit/test_rounds_route.py`
*Stato:* da implementare (B-82, B-95), per i campi marcati · *Oggi:*
*Stato:* da implementare (B-95), per i campi marcati · *Oggi:*
`bet_amount_sats` è sempre il costo configurato.
#### GET /rounds/stream
@@ -760,8 +760,7 @@ dei parametri operativi in
"draw_confirmations", "max_round_pool_sats", "paused", "paused_reason"}`.
*Codice:* `app/api/routes/admin.py:read_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95) · *Oggi:* mancano
`deposit_confirmations` e `max_round_pool_sats`.
*Stato:* da implementare (B-95) · *Oggi:* manca `max_round_pool_sats`.
#### PUT /admin/config
@@ -778,7 +777,7 @@ dei parametri operativi in
| `fee_rate_sat_vb` | 1 – 10 000 (`MAX_FEE_RATE_SAT_VB`) |
| `rbf_timeout_seconds` | 60 – 604 800 |
| `draw_animation_seconds` | 0 – 600 |
| `deposit_confirmations` | 1 – 100 — *da implementare (B-82)* |
| `deposit_confirmations` | 1 – 100 |
| `draw_confirmations` | 1 – 20 |
| `max_round_pool_sats` | `0` (disattivato), oppure da `bet_amount_sats` a 4 000 000 000 000 000 — *da implementare (B-95)* |
@@ -803,9 +802,9 @@ dei parametri operativi in
*Codice:* `app/api/routes/admin.py:update_config`,
`app/api/routes/admin.py:RoundConfigUpdate` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95), per i due campi nuovi e la copia
*Stato:* da implementare (B-95), per `max_round_pool_sats` e la copia
all'apertura di costo ed `fee_address` · *Oggi:* sono accettati i primi
otto campi.
nove campi.
#### POST /admin/pause e POST /admin/resume
@@ -834,9 +833,6 @@ dei parametri operativi in
*Codice:* `app/api/routes/admin.py:pause_lottery`,
`app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_pause_reason.py`
*Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` ·
*Oggi:* `rounds.payout_blocked_at` non esiste ancora; le pause automatiche
esistenti sono solo `resurrected_tx`.
#### GET /admin/users
@@ -893,8 +889,7 @@ dei parametri operativi in
completa di un round è in [API-45](#get-roundsround_idproof).
*Codice:* `app/api/routes/admin.py:list_rounds` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82), per `payout_blocked_at` · *Oggi:*
la lista si ferma a `bump_reserve_sats`.
#### GET /admin/audit-log
+1 -4
View File
@@ -616,8 +616,7 @@ La configurazione è divisa in due posti con regole diverse.
effetti in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md).
*Codice:* `app/rounds/config.py:get_round_config` ·
`app/db/models.py:RoundConfig`
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
`max_round_pool_sats` non esistono.
*Stato:* da implementare (B-95) · *Oggi:* `max_round_pool_sats` non esiste.
- **[ARC-32]** Alcuni valori sono costanti nel codice e non configurabili:
la ripartizione 70/30, le conferme richieste per le transazioni della
piattaforma (1) e per le uscite coinbase (120), i limiti di input e di
@@ -626,8 +625,6 @@ La configurazione è divisa in due posti con regole diverse.
dei depositi sono invece configurabili ([ARC-31]).
*Codice:* `app/rounds/service.py:winner_share` ·
`app/wallet/psbt_builder.py` · `app/tx/confirmation.py`
*Stato:* da implementare (B-82) · *Oggi:* 1 conferma per ogni moneta e
transazione, depositi e coinbase compresi.
## Aggiornamenti in tempo reale (SSE)
+12 -18
View File
@@ -187,8 +187,8 @@ flowchart LR
riga. `user_id` no, `round_id` no. Stessa transazione della modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95), per gli ultimi due campi ·
*Oggi:* i campi sono i primi otto.
*Stato:* da implementare (B-95), per `max_round_pool_sats` ·
*Oggi:* i campi sono i primi nove.
- **[AUD-11]** `lottery_paused` — la lotteria è stata messa in pausa, da
chiunque. Payload `{"reason"}`, più `"note"` quando l'operatore l'ha
@@ -216,10 +216,10 @@ flowchart LR
`app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/tx/reconcile.py:reconcile_once` ·
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-90) · *Oggi:* scritto da
`POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`) e dal
listener (`reorg`); `payout_blocked` e l'avvio con `START_PAUSED` non lo
scrivono ancora.
*Stato:* da implementare (B-90) · *Oggi:* scritto da
`POST /admin/pause` (`operator`), dal riconciliatore (`resurrected_tx`), dal
listener (`reorg`) e dallo scheduler (`payout_blocked`); l'avvio con
`START_PAUSED` non lo scrive ancora.
- **[AUD-12]** `lottery_resumed` — `POST /admin/resume`. Payload
`{"reason"}`: il `paused_reason` che la ripresa ha tolto (`null` se la
@@ -229,9 +229,7 @@ flowchart LR
`paused = false`, `paused_reason = NULL` e dell'azzeramento di
`payout_blocked_at`.
*Codice:* `app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_pause_reason.py`
*Stato:* da implementare (B-82), per l'azzeramento di `payout_blocked_at` ·
*Oggi:* `rounds.payout_blocked_at` non esiste ancora.
*Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
- **[AUD-13]** `admin_privkey_accessed` — `GET /admin/users/{id}/privkey` ha
esportato la chiave privata (WIF) di un utente. Payload `{"user_id"}`.
@@ -491,7 +489,7 @@ flowchart LR
scrive invece `payout_blocked` ([AUD-44]). L'elenco dei motivi è di
[RND-25](ciclo-di-vita-del-round.md#4--pagamento); la verifica senza
quorum di [RND-39](ciclo-di-vita-del-round.md#4--pagamento) termina anche
lei con `payout_failed`, motivo `pool verification unavailable` (B-82). Cosa fare per ogni motivo è descritto in
lei con `payout_failed`, motivo `pool verification unavailable`. Cosa fare per ogni motivo è descritto in
[operativita.md](operativita.md).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`,
`app/rounds/scheduler.py:RoundScheduler._log_payout_failure` ·
@@ -515,10 +513,8 @@ flowchart LR
riscritto dopo l'esclusione. `user_id` sì (il partecipante), `round_id` sì.
Stessa transazione del passaggio a `reorged_out` e della riscrittura di
`pool_amount_sats`. Nessuna logica rilegge la riga.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-82) · *Oggi:* nessun partecipante viene mai
escluso; il pagamento fallisce con `insufficient pool UTXOs`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
*Test:* `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`, `tests/unit/test_pool_verification.py::test_several_bets_gone_at_once_each_get_a_row_in_one_transaction`
- **[AUD-44]** `payout_blocked` — la verifica prima del pagamento ha trovato
assente la scommessa **del vincitore**: il pagamento non si costruisce, il
@@ -532,10 +528,8 @@ flowchart LR
verifica si ripete: se la scommessa è ancora assente si scrive una nuova
riga. Non è un `payout_failed` e non fa da orologio dei tentativi.
Procedura in [operativita.md](operativita.md) (OPS-30).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-82) · *Oggi:* il pagamento fallisce con
`insufficient pool UTXOs` a ogni tentativo, senza pausa.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`
### Riconciliazione
+10 -20
View File
@@ -410,11 +410,7 @@ del montepremi del round.
([RND-38]). La ripartizione è fissata nel codice, non modificabile da
`/admin`.
*Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_payout_builder.py`
*Stato:* da implementare (B-82) · *Oggi:* la ripartizione si calcola
su `pool_amount_sats` fissato all'estrazione, senza la verifica di
[RND-39]; la riserva per l'RBF è già tolta dalla quota delle commissioni
(B-79).
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
- **[RND-22]** Il pagamento spende le uscite delle scommesse dei partecipanti
del round **verificate** ([RND-39]), all'indirizzo del montepremi del round
([RND-35]), e nient'altro: altre monete arrivate a quell'indirizzo non
@@ -427,12 +423,7 @@ del montepremi del round.
dalla più grande fino a coprire il montepremi; non abbastanza monete dà
`insufficient pool UTXOs`.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`, `tests/unit/test_round_pool_address.py`
*Stato:* da implementare (B-82) · *Oggi:* spende le monete confermate
dell'indirizzo del round (per un round vecchio, di quello condiviso) lette
dal solo server attivo, scelte dalla più grande fino a coprire il
montepremi, e non solo le uscite delle scommesse dei partecipanti: un'altra
moneta arrivata a quell'indirizzo verrebbe spesa.
*Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_payout_builder.py`, `tests/unit/test_round_pool_address.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
- **[RND-23]** La transazione ha un'uscita verso l'indirizzo del vincitore,
una verso l'indirizzo delle commissioni fotografato all'apertura
([RND-05]) e una verso l'indirizzo di riserva ([RND-38]). Se gli ingressi
@@ -469,7 +460,7 @@ del montepremi del round.
`insufficient pool UTXOs`, il codice dell'errore di costruzione
(`too_many_inputs`, `winner_share_below_network_fee`,
`commission_below_dust_limit`), `payout build failed` per ogni altro errore
di costruzione, `broadcast rejected`, e — *da implementare (B-82)* —
di costruzione, `broadcast rejected`, e
`pool verification unavailable` quando la verifica del montepremi prima del
pagamento ([RND-39]) non ottiene abbastanza risposte dal quorum. Fanno eccezione due casi, che non
scrivono `payout_failed`: un pagamento già in volo per il round ([RND-26]),
@@ -524,18 +515,17 @@ del montepremi del round.
`paying_out` senza altri tentativi finché l'operatore non toglie la pausa
(`POST /admin/resume`); al tentativo successivo la verifica si ripete. Lo
schema è in [modello-dati.md](modello-dati.md) (B-82), la procedura in
[operativita.md](operativita.md) (B-82).
[operativita.md](operativita.md) (B-82). La riga del vincitore **non**
passa a `reorged_out` in questo caso: resta `confirmed`, così che la
verifica dopo la ripresa la riesamini davvero; i partecipanti diversi dal
vincitore la cui scommessa è assente sono invece esclusi nella stessa
transazione.
- Se il quorum non risponde o non concorda, non si conclude nulla: nessun
partecipante cambia stato, il tentativo termina con `payout_failed` e si
riprova secondo [RND-25].
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* —
*Stato:* da implementare (B-82) · *Oggi:* le monete sono lette dal solo
server attivo e nessun partecipante viene mai escluso: se una scommessa
sparisce dalla catena il pagamento fallisce con
`insufficient pool UTXOs` a ogni tentativo, con i soldi di tutti gli altri
bloccati.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout`, `app/rounds/scheduler.py:RoundScheduler._verify_round_pool`, `app/electrum/listener.py:ElectrumListener.verify_pool_outpoints` ·
*Test:* `tests/unit/test_pool_verification.py`
- **[RND-40]** **Configurazione pagabile**: `rounds_can_open` rifiuta una
configurazione in cui un round non si potrebbe pagare, verificandola per
**n = 1** e per **n = `MAX_PARTICIPANTS_PER_ROUND`** partecipanti (i due
+15 -15
View File
@@ -253,12 +253,10 @@ Ogni decisione che sposta denaro in base a una risposta della rete viene
confermata dagli **altri** server configurati, mai dal solo server attivo
(che è proprio quello che un attaccante controllerebbe).
Oggi non è ancora così per una sola decisione: la lettura delle monete del
montepremi per il pagamento
(`app/rounds/scheduler.py:RoundScheduler._trigger_payout`) si basa sul solo
server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
([ELC-26]), il reintegro di una moneta ricomparsa ([ELC-25]), l'abbandono
([ELC-28]) e l'invio ([TX-04], [TX-21]) usano già gli altri server.
Vale per ogni decisione di questo tipo: le monete del montepremi per il
pagamento ([ELC-27]), le conferme delle transazioni ([ELC-26]), il reintegro
di una moneta ricomparsa ([ELC-25]), l'abbandono ([ELC-28]) e l'invio
([TX-04], [TX-21]) usano gli altri server.
- **[ELC-14]** La corroborazione interroga in parallelo tutti i server
configurati tranne quello attivo, ciascuno con una connessione nuova e un
@@ -286,17 +284,11 @@ server attivo, ed è la riga [ELC-27]. Le conferme delle transazioni
*Codice:* `app/electrum/listener.py:ElectrumListener.agreed_block_hash`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_spent`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_credit`,
`app/electrum/listener.py:ElectrumListener.verify_pool_outpoints`,
`app/electrum/listener.py:ElectrumListener.corroborate_utxo_reinstatement`,
`app/electrum/listener.py:ElectrumListener.confirmation_proven`,
`app/electrum/listener.py:ElectrumListener._corroborate_majority` ·
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`
Stato delle righe della tabella:
- [ELC-17] — *Stato:* da implementare (B-82) · *Oggi:* basta un'altezza
maggiore di 0 (1 conferma).
- [ELC-27] — *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie
fra le monete che il server attivo elenca per il montepremi.
*Test:* `tests/unit/test_electrum_listener.py`, `tests/unit/test_confirmation_proof.py`, `tests/unit/test_deposit_confirmations.py`, `tests/unit/test_pool_verification.py`
I saldi si muovono in entrambe le direzioni, quindi entrambe richiedono lo
stesso quorum. Le conseguenze di una verifica non riuscita:
@@ -311,7 +303,15 @@ stesso quorum. Le conseguenze di una verifica non riuscita:
al controllo successivo;
- un blocco di estrazione non verificato fa attendere e richiedere la stessa
altezza, e lo si registra in audit (`draw_header_corroboration_failed`,
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione)).
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#3--estrazione));
- le monete del montepremi non verificate fanno terminare il tentativo di
pagamento con `payout_failed` (`pool verification unavailable`), senza
escludere nessun partecipante ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)).
La verifica ([ELC-27]) chiede a ogni altro server l'elenco delle monete
dell'indirizzo del round: per ogni uscita di scommessa una maggioranza
stretta di chi risponde dice *presente* (stesso importo, confermata) o
*assente* (non elencata, o con un altro importo); un'uscita elencata solo
nel mempool, o senza maggioranza, non dà alcun verdetto e si riprova.
L'**invio** di una transazione non è una corroborazione ma usa gli stessi
server: dopo che il server attivo l'ha accettata, la transazione grezza viene
+1
View File
@@ -52,6 +52,7 @@ business — quelli si toccano solo da qui.
| **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. |
| **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. |
| **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta più blocchi in sequenza (conferma dell'ultima bet in sospeso, il blocco dell'estrazione con le sue conferme, conferma del payout — un blocco ogni 2 minuti in media), quindi l'animazione può durare più a lungo di questo valore, mai meno. |
| **Conferme per accreditare un deposito** | Da 1 a 100 (predefinito 6): dopo quante conferme un deposito arrivato all'indirizzo di un utente viene accreditato e diventa spendibile. Un deposito è l'unico ingresso che un utente può spendere due volte con una catena privata, perciò il valore non va abbassato senza motivo. Vale subito per gli accrediti successivi; le uscite di mining (coinbase) ne richiedono comunque 120, e le transazioni costruite dalla piattaforma (resto di una bet, vincite) una sola. |
| **Conferme sul blocco di estrazione** | Da 1 a 20 (predefinito 3): quanti blocchi deve avere la catena a partire dal blocco di estrazione prima che il suo hash scelga il vincitore. Più conferme rendono l'estrazione più robusta a una riorganizzazione della catena e allungano l'attesa di circa 2 minuti per blocco. Si applica dal round successivo. |
| **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. |
| **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. |
+9 -4
View File
@@ -102,15 +102,20 @@ L'avviso sparisce da solo appena l'operatore riprende la lotteria.
confermata — così non sembra che il saldo sia crollato più del dovuto
subito dopo un'operazione. Il colore indica lo stato:
- **verde**: tutto confermato, il saldo mostrato è quello definitivo
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma —
il numero è corretto, ma non ancora "finale"
- **arancione**: c'è una bet o un prelievo ancora in attesa di conferma,
oppure un deposito che ha qualche conferma ma non ancora quelle richieste —
il numero è corretto, ma non ancora "finale"; un deposito in attesa non è
spendibile finché non viene accreditato
- Il tuo **indirizzo di deposito**, con bottone per copiarlo negli appunti
- Il **QR code** dello stesso indirizzo, comodo per inviare PLM da un altro
wallet scansionandolo invece di copiare l'indirizzo a mano
Per depositare, invia PLM (mainnet reale) a quell'indirizzo da un wallet
esterno. Il saldo si aggiorna da solo dopo la prima conferma (e quasi subito,
grazie all'aggiornamento in tempo reale); premi "Aggiorna" se vuoi comunque
esterno. Un deposito viene accreditato (e diventa spendibile) dopo
**6 conferme** sulla rete (il valore è configurabile dall'operatore; un'uscita
di mining ne richiede 120), cioè circa 12 minuti; nel frattempo compare in
arancione come in attesa. Il saldo si aggiorna da solo, con l'aggiornamento in
tempo reale; premi "Aggiorna" se vuoi comunque
ricontrollarlo a mano.
### Bet
+10 -46
View File
@@ -36,8 +36,8 @@ implementato il [piano delle correzioni](piano-correzioni.md).
| [LIM-02] RBF di una sola forma (e pagamento senza resto) | **Sì, in caso di congestione della rete** | Chiuso da B-79 e B-87 |
| [LIM-03] Attesa in `closing` senza limite di tempo | **Sì, in caso di congestione** | Resta senza limite di tempo per scelta ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)); con B-78 e B-87 finisce quando la rete decide |
| [LIM-04] Prelievo e RBF mai provati in rete reale | **Sì, come verifica** | Da chiudere con la prova su rete reale ([operativita.md](operativita.md#prova-su-rete-reale)) prima di aprire al pubblico |
| [LIM-10] Riorganizzazioni non gestite | **Sì** — una scommessa sparita rende il round impagabile | Chiuso da B-82 e B-85 (la pausa e il registro sono attivi; resta il pagamento, B-82); resta [LIM-24] |
| [LIM-11], [LIM-12] Quorum facoltativo e parziale | **Sì** — un solo server decide conferme e abbandoni | Chiusi da B-83 e B-84 (resta la lettura delle monete del montepremi, B-82) |
| ~~[LIM-10]~~ Riorganizzazioni non gestite | Non più (chiuso da B-82 e B-85) | Resta [LIM-24] |
| [LIM-11], ~~[LIM-12]~~ Quorum facoltativo e parziale | Non più (chiusi da B-82, B-83 e B-84) | Resta la scelta dell'operatore di server indipendenti ([LIM-11]) |
| [LIM-13] Estrazione manipolabile | **Sì** — per un giocatore che mina | Chiuso da B-73; resta [LIM-25] |
| [LIM-14] Header senza difficoltà minima | **Sì** — un header falso congela ogni estrazione | Chiuso da B-74 per il blocco; resta il costo basso di un header a difficoltà minima |
| [LIM-06] Token admin unico | No per il funzionamento, ma è il rischio di sicurezza più ampio | Invariato |
@@ -211,30 +211,11 @@ implementato il [piano delle correzioni](piano-correzioni.md).
### Fiducia nella rete
- **[LIM-10]** **Riorganizzazioni non gestite, depositi a 1 conferma.** Un
deposito accreditato a 1 conferma può sparire con una riorganizzazione; la
punta della catena non regredisce mai, un header concorrente alla stessa
altezza è ignorato e nessuna riorganizzazione viene rilevata.
- *Impatto:* il caso grave è una scommessa **già confermata** che sparisce:
un utente scommette con un deposito a 1 conferma e, se perde, sostituisce
il deposito con una catena privata. La scommessa non è più in catena, ma il
partecipante resta `confirmed` e contato, e il pagamento fallisce per
sempre con «monete del montepremi insufficienti», con il denaro di tutti
gli altri giocatori bloccato. Anche le uscite coinbase sono accreditate a 1
conferma, mentre maturano a 120.
- *Mitigazione attuale:* una moneta che sparisce dall'elenco del server viene,
previo quorum, segnata come spesa all'esterno e tolta dal saldo; se
riappare viene ripristinata. Una scommessa **non ancora inviata** costruita
su una moneta scomparsa è rifiutata dalla rete e annullata; per una
scommessa già confermata non esiste alcuna mitigazione (la versione
precedente di questa voce diceva il contrario).
- *Stato:* da chiudere con B-82; le riorganizzazioni sono già rilevate, registrate e (se profonde) in pausa (B-85).
- *Per chiuderlo:* depositi a `deposit_confirmations` conferme e coinbase a
120 ([ADR-06](adr/ADR-06-depositi-richiedono-deposit-confirmations-conferme-6.md)); riorganizzazioni rilevate, registrate e, se profonde,
con pausa; monete del round verificate prima del pagamento, con i
partecipanti spariti esclusi ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
*Codice:* `app/deposits/service.py:find_utxos_missing_from` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/electrum/listener.py:ElectrumListener._apply_header`
- ~~[LIM-10]~~ Rimosso: chiuso da B-82 e B-85. I depositi si accreditano a
`deposit_confirmations` conferme (le coinbase a 120), le riorganizzazioni sono
seguite, registrate e, da profondità 2, mettono in pausa; le monete del round
si verificano con il quorum prima del pagamento e i partecipanti spariti
restano fuori ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)). Resta accettato il 51% ([LIM-24]).
- **[LIM-11]** **I server del quorum li sceglie l'operatore.** Il server
rifiuta di partire con meno di 2 server di riserva e ogni corroborazione
@@ -253,26 +234,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
*Codice:* `app/electrum/listener.py:ElectrumListener._corroborate_majority`
- **[LIM-12]** **Non tutte le letture di rete sono corroborate.** Passano dal
solo server attivo: l'elenco delle monete del montepremi usato per il
pagamento.
- *Impatto:* un server ostile potrebbe far scegliere al pagamento monete che
non esistono (il pagamento verrebbe rifiutato dalla rete) o nasconderne
di vere (il pagamento fallirebbe e verrebbe ritentato).
- *Mitigazione attuale:* l'abbandono richiede una prova ([ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md)) e l'invio
arriva a tutti i server; un invio basato su monete inesistenti è rifiutato
dalla rete.
- *Stato:* da chiudere con B-82 (monete del round prima del pagamento);
conferme e ripristini sono chiusi da B-83.
- *Per chiuderlo:* una conferma vale quando il quorum restituisce una prova
merkle valida (`blockchain.transaction.get_merkle`) contro un header che
possediamo; il ripristino richiede lo stesso quorum di un accredito;
l'abbandono richiede la prova di [ADR-51](adr/ADR-51-dubbio-liberano-mai-fondi.md); le monete del round si
verificano con il quorum prima di costruire il pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md)); ogni
transazione accettata dal server attivo viene inviata anche a tutti i
server di riserva.
*Codice:* `app/tx/confirmation.py:poll_once` · `app/tx/reconcile.py:reconcile_once` · `app/deposits/service.py:reinstate_reappeared_utxos` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout`
- ~~[LIM-12]~~ Rimosso: chiuso da B-83 e B-82. Conferme (prova merkle),
reintegri, abbandoni e le monete del montepremi usate per il pagamento sono
tutti decisi dal quorum degli altri server, mai dal solo server attivo.
- **[LIM-13]** **L'estrazione attuale è manipolabile da chi produce o
trasmette i blocchi.**
+28 -40
View File
@@ -345,9 +345,7 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
*Codice:* `app/deposits/service.py:find_new_credit_candidates` ·
`app/deposits/service.py:credit_confirmed_utxos` ·
`app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82) · *Oggi:* si accredita ogni output
con `height > 0` (1 conferma), coinbase comprese.
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_electrum_listener.py`, `tests/unit/test_deposit_confirmations.py`
- **[DAT-11]** Gli UTXO dei montepremi e della riserva **non** sono in
`utxo_events`: non appartengono a un utente. Il pagamento di un round spende
@@ -360,11 +358,10 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso
lo stesso motivo l'abbandono di un pagamento non ha input da liberare.
*Codice:* `app/rounds/scheduler.py:_reserved_payout_outpoints` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra tutti gli
UTXO confermati dell'indirizzo del round (per un round vecchio, del
condiviso), lette dal solo server attivo, e non solo fra le uscite delle
scommesse dei partecipanti.
`app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else`
Un round aperto prima dell'indirizzo per round (il cui montepremi è
l'indirizzo di riserva condiviso) conserva la scelta di prima: le monete
confermate dell'indirizzo, lette dal server attivo.
- **[DAT-12]** `spent_txid` ha tre significati:
- `NULL`: l'output è spendibile e conta nel saldo;
@@ -443,12 +440,12 @@ restano nella tabella come storico.
| `draw_block_hash` | String(64) | sì | `NULL` | Hash (esadecimale) di quel blocco | `_complete_draw` |
| `seed_int` | String(128) | sì | `NULL` | Il seme in decimale (fino a 78 cifre, per questo è una stringa). Il valore intero di `sha256(server_seed ‖ block_hash ‖ participants_hash)`, con la codifica definita in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (RND-17) | `_complete_draw` |
| `winner_user_id` | FK `users.id` | sì | `NULL` | Il vincitore | `_complete_draw` |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) |
| `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (la ridefinisce la verifica, solo al ribasso) |
| `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` |
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` |
| `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` | `_trigger_payout` |
| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` |
| `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` |
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
| `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` |
- ~~**[DAT-15]**~~ Assorbito da
[RND-02](ciclo-di-vita-del-round.md): i valori di `status` e le transizioni
@@ -622,10 +619,8 @@ restano nella tabella come storico.
database**. Il valore scritto è sempre minore o uguale al precedente.
La regola di dominio (quorum, ripartizione su ciò che c'è) è in
[ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-82).
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* —
*Stato:* da implementare (B-82) · *Oggi:* `pool_amount_sats` non cambia dopo
l'estrazione, e un pagamento che non trova tutte le scommesse fallisce con
"insufficient pool UTXOs" a ogni tentativo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` ·
*Test:* `tests/unit/test_pool_verification.py`
- **[DAT-68]** `payout_blocked_at` si scrive quando la verifica di [DAT-67]
trova assente la scommessa del **vincitore**: il round resta `paying_out`,
@@ -634,8 +629,8 @@ restano nella tabella come storico.
([DAT-69]). Torna `NULL` solo con `POST /admin/resume` ([api.md](api.md)),
dopo di che la verifica si ripete al tentativo successivo.
*Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/api/routes/admin.py:resume_lottery` · *Test:* —
*Stato:* da implementare (B-82) · *Oggi:* la colonna non esiste.
`app/api/routes/admin.py:resume_lottery` ·
*Test:* `tests/unit/test_pool_verification.py::test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery`, `tests/unit/test_pool_verification.py::test_after_the_resume_the_verification_repeats`, `tests/unit/test_admin.py::test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain`
- **[DAT-73]** Ogni pagamento ha un output di **riserva** verso l'indirizzo
di riserva (`m/84'/746'/0'/1/0`), di valore
@@ -691,7 +686,7 @@ Una scommessa: un utente in un round.
broadcast --> confirmed: _on_bet_confirmed
building --> [*]: cancellata (_release_failed_bet / _abandon)
broadcast --> [*]: cancellata (_abandon)
confirmed --> reorged_out: verifica prima del pagamento (da implementare)
confirmed --> reorged_out: verifica prima del pagamento
confirmed --> [*]
reorged_out --> [*]
```
@@ -703,7 +698,7 @@ Una scommessa: un utente in un round.
| `building`, `broadcast` | `confirmed` | `app/bets/confirmation.py:_on_bet_confirmed` |
| `building` | cancellata | `app/bets/service.py:_release_failed_bet` (broadcast rifiutato esplicitamente) |
| `building`, `broadcast` | cancellata | `app/tx/reconcile.py:_abandon` (abbandono con prova) |
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._trigger_payout` (da implementare) |
| `confirmed` | `reorged_out` | `app/rounds/scheduler.py:RoundScheduler._verify_round_pool` (da `_trigger_payout`), per ogni partecipante tranne il vincitore ([RND-39](ciclo-di-vita-del-round.md#4--pagamento)) |
Se il compare-and-set di [DAT-16] fallisce, la riga `building` non arriva
nemmeno al commit. Un broadcast dall'esito ambiguo (timeout, connessione
@@ -713,10 +708,9 @@ Una scommessa: un utente in un round.
`app/bets/service.py:_release_failed_bet` · `app/tx/reconcile.py:_promote` ·
`app/tx/reconcile.py:_abandon` · `app/bets/confirmation.py:_on_bet_confirmed` ·
*Test:* `tests/unit/test_bets.py`, `tests/unit/test_confirmation.py`,
`tests/unit/test_reconcile.py`
*Stato:* da implementare (B-82, B-76) · *Oggi:* `reorged_out` non esiste (un
partecipante confermato la cui scommessa sparisce resta `confirmed`), e
qualunque eccezione del broadcast cancella la riga come un rifiuto.
`tests/unit/test_reconcile.py`, `tests/unit/test_pool_verification.py`
*Stato:* da implementare (B-76) · *Oggi:* qualunque eccezione del broadcast
cancella la riga come un rifiuto.
- **[DAT-25]** `broadcast_at` è l'istante della fase 1 di `place_bet`, appena
prima del broadcast, e non viene mai riscritto (nemmeno da un bump RBF). Non
@@ -785,7 +779,7 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
| `draw_animation_seconds` | Integer | `20` | 0 – 600 s | Durata minima dell'animazione dell'estrazione nel frontend; non condiziona l'estrazione vera | Subito (letto da `GET /rounds/current`) |
| `fee_rate_sat_vb` | Integer | `1` | 1 – `MAX_FEE_RATE_SAT_VB` (10 000) | Commissione di rete delle nuove transazioni | Subito, per le transazioni costruite dopo; quelle in volo tengono il proprio `fee_rate_sat_vb` |
| `rbf_timeout_seconds` | Integer | `900` | 60 s – 7 giorni | Dopo quanto senza conferme si fa un bump RBF | Subito, anche per le transazioni già in volo (il bumper lo rilegge a ogni giro) |
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) — *da implementare (B-82)* | Subito, per gli accrediti successivi; gli output già accreditati restano |
| `deposit_confirmations` | Integer | `6` | 1 – 100 | Conferme richieste per accreditare un deposito ([DAT-62]) | Subito, per gli accrediti successivi; gli output già accreditati restano |
| `draw_confirmations` | Integer | `3` | 1 – 20 | Conferme richieste sul blocco di estrazione ([DAT-62]) | Dal round **successivo**, copiato all'apertura ([DAT-61]) |
| `max_round_pool_sats` | BigInteger | `0` (tetto disattivato) | `0`, oppure `≥ bet_amount_sats` e `≤ 4 000 000 000 000 000` (400 × 100 000 PLM) | Tetto alla somma delle scommesse di un round ([DAT-27], [DAT-62]) — *da implementare (B-95)* | Subito, anche per il round in corso: vale per le scommesse successive |
| `paused` | Boolean | `False` | non modificabile da `PUT /admin/config` | Pausa della lotteria | Subito per l'apertura del round successivo; il round in corso non è toccato |
@@ -810,8 +804,8 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
valore uguale a quello attuale non conta come modifica.
*Codice:* `app/api/routes/admin.py:update_config` ·
`app/api/routes/admin.py:RoundConfigUpdate` · *Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95), per le ultime due colonne ·
*Oggi:* le colonne modificabili sono le prime otto.
*Stato:* da implementare (B-95), per l'ultima colonna ·
*Oggi:* le colonne modificabili sono le prime nove.
- **[DAT-30]** `paused` e `paused_reason` si modificano solo con
`POST /admin/pause` e `POST /admin/resume`, che scrivono `lottery_paused` e
@@ -819,8 +813,6 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
[DAT-69], che scrivono la propria voce di audit.
*Codice:* `app/api/routes/admin.py:pause_lottery` ·
`app/api/routes/admin.py:resume_lottery` · *Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82) · *Oggi:* le pause automatiche sono
`resurrected_tx` (B-78) e `reorg` (B-85); quella di B-82 non esiste ancora.
- **[DAT-62]** `deposit_confirmations` (predefinito 6) è il numero di
conferme dopo cui un output che paga un utente si accredita ([DAT-10]);
@@ -834,10 +826,9 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
[scommesse-e-prelievi.md](scommesse-e-prelievi.md) (B-95). La migrazione le
aggiunge con un `server_default` temporaneo pari al predefinito ([DAT-55]).
*Codice:* `app/db/models.py:RoundConfig` · `app/rounds/config.py:get_round_config` ·
*Test:* `tests/unit/test_admin.py`
*Stato:* da implementare (B-82, B-95) · *Oggi:* `deposit_confirmations` e
`max_round_pool_sats` non esistono: i depositi usano 1 conferma e non c'è
tetto; `draw_confirmations` esiste.
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_migrations.py`
*Stato:* da implementare (B-95) · *Oggi:* `max_round_pool_sats` non esiste:
non c'è tetto.
- **[DAT-69]** `paused_reason` è `NULL` esattamente quando `paused` è falso;
con `paused` vero vale uno di questi valori, e nessun altro:
@@ -861,9 +852,10 @@ senza riavviare (vedi [limiti-noti.md](limiti-noti.md)).
`app/electrum/listener.py:ElectrumListener._record_reorg` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/tx/reconcile.py:reconcile_once` · *Test:* `tests/unit/test_pause_reason.py`, `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-90) · *Oggi:* si scrivono
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore) e
`reorg` (il listener); `payout_blocked` e `START_PAUSED` non hanno ancora chi li scrive.
*Stato:* da implementare (B-90) · *Oggi:* si scrivono
`operator` (`POST /admin/pause`), `resurrected_tx` (il riconciliatore),
`reorg` (il listener) e `payout_blocked` (lo scheduler); `START_PAUSED` non ha
ancora chi lo scrive.
- **[DAT-31]** Con `fee_address` vuoto (o di soli spazi) non si apre nessun round
(B-66). Una volta impostato, l'API non permette di svuotarlo di nuovo: la
@@ -1274,11 +1266,7 @@ letti con una scansione completa.
*Codice:* `app/rounds/service.py:winner_share` ·
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
`app/wallet/psbt_builder.py:build_payout_transaction` ·
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`
*Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra
tutti gli UTXO confermati dell'indirizzo del round, senza la verifica di
[DAT-67]; l'identità con la riserva vale già per i round pagati con tutti
e soli i loro ingressi.
*Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py`, `tests/unit/test_pool_verification.py::test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there`
- ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa
delle scommesse in volo prima dell'estrazione è comportamento del round. Il
+9 -7
View File
@@ -561,7 +561,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
(`m/84'/746'/0'/1/0`, anche il montepremi dei round aperti prima di B-80) si
ottiene con
`docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"`;
se ne può controllare il saldo con un explorer. Dopo B-82 un'uscita di
se ne può controllare il saldo con un explorer. Un'uscita di
scommessa scomparsa dalla catena esclude quel partecipante invece di
bloccare il pagamento; solo la scommessa scomparsa del vincitore lo blocca
([OPS-30]).
@@ -639,10 +639,12 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
- **[OPS-18]**
**Sintomi.** Un utente segnala un deposito che non vede nel saldo.
**Cause, in ordine di probabilità.**
1. La transazione non ha ancora le conferme richieste: oggi 1, dopo B-82
`deposit_confirmations` (predefinito 6) e 120 per un'uscita coinbase
1. La transazione non ha ancora le conferme richieste:
`deposit_confirmations` (predefinito 6, modificabile da `/admin`) e 120
per un'uscita coinbase
([registrazione-e-depositi.md](registrazione-e-depositi.md#deposito)).
Il deposito non viene mostrato nemmeno come "in attesa" finché non le ha.
Nel frattempo compare come deposito *in attesa* nel saldo mostrato
(ambra), non spendibile; si accredita da solo al blocco in cui matura.
2. La corroborazione fallisce: nel log
`N new UTXO(s) for user_id=… not corroborated by the other servers` e
spesso `could not corroborate … with any of N other configured server(s)`.
@@ -790,9 +792,9 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
`app/tx/confirmation.py:poll_once`,
`app/rounds/scheduler.py:RoundScheduler._trigger_payout` ·
*Test:* `tests/unit/test_electrum_listener.py`
*Stato:* da implementare (B-82, B-90) · *Oggi:* il sistema mette in
pausa la lotteria per `resurrected_tx` (B-78) e `reorg` (B-85);
`payout_blocked` non esiste ancora.
*Stato:* da implementare (B-90) · *Oggi:* il sistema mette in pausa la
lotteria per `resurrected_tx` (B-78), `reorg` (B-85) e `payout_blocked` (B-82);
l'avvio con `START_PAUSED=true` non esiste ancora.
### Intervento manuale sui fondi
+14 -20
View File
@@ -213,10 +213,8 @@ flowchart TB
H -- "Sì, e il quorum<br/>la conferma non spesa" --> I["Reintegrata<br/>(utxo_external_spend_reinstated)"]
```
*Comportamento atteso.* Oggi ogni moneta confermata si accredita a 1
conferma, di qualunque importo e anche se coinbase; il reintegro non è
corroborato; gli aggiornamenti dello stesso utente non sono in fila; le
monete sotto la soglia non compaiono nel saldo mostrato.
*Comportamento atteso.* Gli aggiornamenti dello stesso utente non sono
ancora in fila ([DEP-15], B-96).
- **[DEP-01]** Non c'è un importo minimo di deposito oltre alla soglia di
polvere: una moneta di valore inferiore a `DUST_LIMIT_SATS` (294 sat) non
@@ -260,10 +258,8 @@ monete sotto la soglia non compaiono nel saldo mostrato.
Una moneta accreditata e poi sparita per una riorganizzazione più profonda
della soglia segue la strada delle monete spese altrove
([DEP-06], [DEP-07]); non c'è altra logica di annullamento.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/electrum/listener.py:ElectrumListener.refresh_user` ·
*Test:* `tests/unit/test_deposits.py`
*Stato:* da implementare (B-82) · *Oggi:* **una conferma basta** per ogni
moneta, deposito o coinbase che sia.
*Codice:* `app/deposits/service.py:find_new_credit_candidates`, `app/deposits/service.py:read_deposit_policy`, `app/deposits/service.py:classify_new_deposits`, `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.is_coinbase` ·
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_deposit_confirmations.py`
- **[DEP-04]** **Ogni accredito è corroborato.** Prima di aumentare un saldo,
il server chiede agli *altri* server Electrum configurati se quella moneta
esiste davvero, con quell'importo e con le conferme richieste
@@ -312,11 +308,13 @@ monete sotto la soglia non compaiono nel saldo mostrato.
*Test:* `tests/unit/test_deposits.py`, `tests/unit/test_confirmation_proof.py`
- **[DEP-09]** **Due vie, un solo codice.** Un aggiornamento parte quando il
server notifica un cambiamento su un indirizzo osservato, alla
(re)iscrizione di un indirizzo, e al giro periodico: tutte e tre usano la
stessa funzione di aggiornamento, così i due percorsi non possono
comportarsi diversamente.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`
(re)iscrizione di un indirizzo, al giro periodico e, per gli utenti che
hanno un deposito in attesa ([DEP-14]), a ogni nuovo blocco — la notifica
dell'indirizzo non arriva quando un deposito matura, perché la sua
cronologia non cambia —: tutti usano la stessa funzione di aggiornamento,
così i percorsi non possono comportarsi diversamente.
*Codice:* `app/electrum/listener.py:ElectrumListener._consume_scripthash`, `app/electrum/listener.py:ElectrumListener._subscribe_and_refresh`, `app/electrum/listener.py:ElectrumListener._refresh_waiting_deposits`, `app/deposits/reconcile.py:DepositReconciler._sweep_once` ·
*Test:* `tests/unit/test_deposit_reconcile.py`, `tests/unit/test_deposit_confirmations.py::test_new_blocks_refresh_the_users_that_have_a_waiting_deposit`
- **[DEP-10]** <a id="dep-10"></a>Il giro periodico (`DepositReconciler`)
riaggiorna **tutti** gli utenti, non solo quelli che risultano non
iscritti: serve a recuperare un'iscrizione persa silenziosamente. Prima di
@@ -388,9 +386,7 @@ corso (`has_pending`).
contata due volte. `has_pending` è vero finché esiste almeno una
transazione di (a) o un deposito di (b).
*Codice:* `app/wallet/balance.py:compute_pending_balance`, `app/api/routes/users.py:me` ·
*Test:* `tests/unit/test_balance.py`
*Stato:* da implementare (B-82), per la parte (b) · *Oggi:* conta solo la
parte (a).
*Test:* `tests/unit/test_balance.py`, `tests/unit/test_deposit_confirmations.py::test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not`, `tests/unit/test_users.py::test_me_shows_waiting_deposits_in_the_pending_balance_only`
- **[DEP-14]** **Depositi in attesa.** Una moneta sopra la soglia di polvere,
confermata (altezza maggiore di 0), non ancora registrata e con meno
conferme della sua soglia ([DEP-03]) è un *deposito in attesa*. Il listener
@@ -401,10 +397,8 @@ corso (`has_pending`).
non sposta denaro e non è spendibile, quindi non è corroborato — e dopo un
riavvio si ricostruisce alla prima reiscrizione ([ELC-18](connessione-electrum.md#riconnessione)).
Le monete ancora in mempool non sono depositi in attesa.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/wallet/balance.py:compute_pending_balance` · *Test:* —
*Stato:* da implementare (B-82) · *Oggi:* con la soglia a 1 conferma non
esistono depositi in attesa; una moneta in mempool non compare in nessun
saldo.
*Codice:* `app/electrum/listener.py:ElectrumListener.refresh_user`, `app/electrum/listener.py:ElectrumListener.pending_deposit_sats`, `app/wallet/balance.py:compute_pending_balance` ·
*Test:* `tests/unit/test_deposit_confirmations.py::test_a_deposit_is_credited_exactly_at_six_confirmations`, `tests/unit/test_deposit_confirmations.py::test_a_waiting_deposit_that_vanishes_stops_being_shown`
- **[DEP-13]** Ogni spesa (scommessa, prelievo) usa solo monete accreditate e
non spese: il saldo mostrato è informativo, non spendibile. Se un utente
chiede un prelievo o una scommessa coperti dal saldo mostrato ma non ancora
+2 -3
View File
@@ -90,8 +90,7 @@ Restano **accettati**, e descritti in
riorganizzazioni con pausa sopra la profondità 1 e verifica delle monete
del round prima del pagamento ([ADR-52](adr/ADR-52-riorganizzazioni-catena-contengono-invece-ignorarle.md), B-82,
B-85). Il denaro che un attaccante riesce a sottrarre è perso per la
piattaforma. *Oggi:* depositi a 1 conferma e nessuna verifica prima
del pagamento (B-82); le riorganizzazioni sono rilevate e, da profondità 2, mettono in pausa.
piattaforma.
## Inventario dei segreti
@@ -799,7 +798,7 @@ sicurezza.
([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-82): un round
del backup già pagato risulta impagabile invece di essere pagato due volte
con il denaro dei round successivi. *Oggi:* la pausa si imposta a mano nel
database, e le monete del round non sono ancora verificate con il quorum.
database.
- **Chiave sbagliata.** Una master key che non corrisponde al database
ripristinato ferma l'avvio ([SEC-49]).
- **Commit persi.** Con `synchronous=FULL` un commit confermato al chiamante
+3 -3
View File
@@ -230,7 +230,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | *da registrare* | `test_payout_builder.py`, `test_broadcast.py`, `test_scheduler.py`, `test_rounds_service.py`, `test_admin.py`, `test_migrations.py` | RND, TX |
| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR |
| B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | *da registrare* | `test_draw_proof.py` | RND |
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND |
| B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | *da registrare* | `test_deposit_confirmations.py`, `test_pool_verification.py`, `test_electrum_listener.py`, `test_admin.py`, `test_users.py` | DEP, RND |
| B-83 | Conferme e reintegri si basano solo sul server attivo | Alta | *da registrare* | `test_confirmation_proof.py`, `test_confirmation.py` | ELC, TX, DEP |
| B-84 | Il quorum può ridursi a un solo server o a nessuno senza avvisi; certificati TLS non verificati | Alta | *da registrare* | `test_electrum_client.py`, `test_electrum_listener.py` | ELC, SEC |
| B-85 | Le riorganizzazioni della catena non vengono né seguite né segnalate | Alta | *da registrare* | `test_electrum_listener.py` | ELC, RND |
@@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
- **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79.
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82.
- **Aperti:** B-71, B-72 (gravità bassa, operativi o di documentazione) e
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (1 critico,
B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (0 critici,
4 alti, 6 medi), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-99`.
@@ -0,0 +1,44 @@
"""deposit confirmations and the held payout: round_config.deposit_confirmations, rounds.payout_blocked_at (B-82)
Revision ID: f2c64a8d1e93
Revises: e5a91c3d7b26
Create Date: 2026-10-15 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f2c64a8d1e93'
down_revision: Union[str, Sequence[str], None] = 'e5a91c3d7b26'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_ACTIVE = "'open', 'closing', 'drawing', 'paying_out'"
def upgrade() -> None:
"""Upgrade schema.
B-82 (DAT-62, DAT-68): `round_config.deposit_confirmations` (default 6) is how many
confirmations an output paying a user needs before it is credited as a deposit;
`rounds.payout_blocked_at` marks a payout held because the winner's own bet left the
chain. Both are plain add_column calls — never batch_alter_table, which on SQLite
rebuilds the table and silently drops the expression indexes (B-97, DAT-56).
"""
op.add_column(
"round_config", sa.Column("deposit_confirmations", sa.Integer(), server_default="6", nullable=False)
)
op.add_column("rounds", sa.Column("payout_blocked_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("rounds", "payout_blocked_at")
op.drop_column("round_config", "deposit_confirmations")
# drop_column on SQLite is a table rebuild: recreate the expression index (B-97).
op.execute("DROP INDEX IF EXISTS ix_rounds_single_active")
op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})")
+16
View File
@@ -19,6 +19,12 @@ class FakeNetwork:
self.asked: list[str] = []
self.fanned_out: list[str] = []
self.proofs_asked: list[tuple[str, int]] = []
# B-82: what `verify_pool_outpoints` answers. Every bet output is present unless listed
# in `pool_absent`; `pool_unavailable` models a quorum that could not be reached.
self.pool_absent: set[tuple[str, int]] = set()
self.pool_unavailable = False
self.verified_scripthashes: list[str] = []
self.verified_outpoints: list[list[tuple[str, int, int]]] = []
async def transaction_known(self, txid: str) -> bool | None:
self.asked.append(txid)
@@ -31,6 +37,16 @@ class FakeNetwork:
self.proofs_asked.append((txid, height))
return self.proven
async def verify_pool_outpoints(self, scripthash, outpoints):
self.verified_scripthashes.append(scripthash)
self.verified_outpoints.append(list(outpoints))
hook = getattr(self, "on_verify", None)
if hook is not None:
await hook()
if self.pool_unavailable:
return None
return {(txid, vout): (txid, vout) not in self.pool_absent for txid, vout, _amount in outpoints}
MISSING_INPUTS = "bad-txns-inputs-missingorspent"
+46
View File
@@ -265,6 +265,8 @@ async def test_config_update_without_changes_logs_nothing(client):
{"rbf_timeout_seconds": 1},
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
{"draw_confirmations": 21},
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
{"deposit_confirmations": 101},
],
)
async def test_config_rejects_unusable_values(client, payload):
@@ -466,3 +468,47 @@ async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client)
assert resp.status_code == 200
# an unrelated edit is never blocked by the payability check
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
for value in (1, 100, 12):
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
audit = (await client.get("/admin/audit-log", headers=headers)).json()
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
assert {"from": 100, "to": 12} in changes
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import base as db_base
from app.db.models import AuditLog, Round
headers = {"X-Admin-Token": "test-admin-token"}
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
await conn.run_sync(db_base.Base.metadata.create_all)
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
session.add(Round(status="closed"))
await session.commit()
async with db_base.AsyncSessionLocal() as session:
from app.rounds.pause import pause_automatically
await pause_automatically(session, "payout_blocked")
await session.commit()
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
resp = await client.post("/admin/resume", headers=headers)
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
async with db_base.AsyncSessionLocal() as session:
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
assert resumed and '"payout_blocked"' in resumed[0].payload_json
+1
View File
@@ -366,6 +366,7 @@ async def _flagged_user(session_factory) -> int:
def _reinstate_listener(session_factory, others: dict[str, _UnspentServer], active_entries):
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: far past every credit threshold, coinbase maturity included
listener.client = _ActiveClient(active_entries)
return listener
+312
View File
@@ -0,0 +1,312 @@
"""B-82 (DEP-03, DEP-04, DEP-12, DEP-14, ELC-17, DAT-10, DAT-62, API-31): a deposit is
credited at `deposit_confirmations` (default 6), a coinbase output at its maturity of 120, and
only an output of a platform transaction at 1 — the deposit being the one input a user can
double-spend with a private chain.
The real listener is driven here: a fake active connection, fake "other" servers for the
quorum, real transactions so the coinbase check reads (and hash-checks) an actual raw tx.
"""
import pytest
from embit import script
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from sqlalchemy import select
from app.db.models import AuditLog, PendingTransaction, PendingTransactionTxid, RoundConfig, User, UtxoEvent
from app.deposits.service import (
COINBASE_MATURITY,
DepositPolicy,
classify_new_deposits,
find_new_credit_candidates,
)
from app.electrum.listener import ElectrumListener
from app.wallet.balance import compute_pending_balance
from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture)
_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
_OUT = script.Script.from_address(_ADDRESS)
def _plain_tx(seed: int = 1) -> Transaction:
return Transaction(
vin=[TransactionInput(bytes([seed]) * 32, 0)], vout=[TransactionOutput(1_000_000_000, _OUT)]
)
def _coinbase_tx() -> Transaction:
return Transaction(vin=[TransactionInput(bytes(32), 0xFFFFFFFF)], vout=[TransactionOutput(1_000_000_000, _OUT)])
def _entry(tx: Transaction, height: int, value: int = 1_000_000_000) -> dict:
return {"tx_hash": tx.txid().hex(), "tx_pos": 0, "height": height, "value": value}
class _Active:
"""The listener's active connection: the address' coins, and raw transactions by txid."""
def __init__(self, entries, raws: dict[str, str] | None = None):
self.entries = entries
self.raws = raws or {}
self.asked_for: list[str] = []
async def listunspent(self, scripthash):
return self.entries
async def get_transaction(self, txid, verbose=False):
self.asked_for.append(txid)
return self.raws[txid]
class _Other:
def __init__(self, entries):
self._entries = entries
async def connect(self):
pass
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
async def _user(session_factory, *, deposit_confirmations: int | None = None) -> int:
async with session_factory() as session:
if deposit_confirmations is not None:
session.add(RoundConfig(fee_address="", deposit_confirmations=deposit_confirmations))
user = User(username="alice", password_hash="x", derivation_index=0, address=_ADDRESS)
session.add(user)
await session.commit()
return user.id
def _listener(session_factory, active: _Active, others_entries, *, tip: int) -> ElectrumListener:
others = {host: _Other(others_entries[host]) for host in ("second.example", "third.example")}
listener = ElectrumListener(lambda endpoint: others[endpoint.host], session_factory, _ENDPOINTS)
listener.client = active
listener.tip_height = tip
return listener
async def _balance(session_factory, user_id: int) -> int:
async with session_factory() as session:
return (await session.get(User, user_id)).cached_balance_sats
def _same(entries):
return {"second.example": entries, "third.example": entries}
# --- the thresholds (DEP-03) -----------------------------------------------------------------------
async def test_a_deposit_is_credited_exactly_at_six_confirmations(session_factory):
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100) # confirmations = tip - 100 + 1
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
at_five = _listener(session_factory, active, _same([entry]), tip=104) # 5 confirmations
await at_five.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
assert at_five.pending_deposit_sats(user_id) == 1_000_000_000 # waiting, and shown as such
at_six = _listener(session_factory, active, _same([entry]), tip=105) # 6 confirmations
await at_six.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 1_000_000_000
assert at_six.pending_deposit_sats(user_id) == 0
async def test_deposit_confirmations_is_read_from_the_configuration(session_factory):
user_id = await _user(session_factory, deposit_confirmations=2)
tx = _plain_tx()
entry = _entry(tx, height=100)
active = _Active([entry], {tx.txid().hex(): tx.serialize().hex()})
await _listener(session_factory, active, _same([entry]), tip=100).refresh_user(user_id, "sh") # 1 conf
assert await _balance(session_factory, user_id) == 0
await _listener(session_factory, active, _same([entry]), tip=101).refresh_user(user_id, "sh") # 2 confs
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_an_immature_coinbase_is_not_credited_until_it_has_120_confirmations(session_factory):
user_id = await _user(session_factory)
tx = _coinbase_tx()
entry = _entry(tx, height=100)
txid = tx.txid().hex()
active = _Active([entry], {txid: tx.serialize().hex()})
# plenty for a deposit (50 confirmations), not for a coinbase
immature = _listener(session_factory, active, _same([entry]), tip=149)
await immature.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
assert immature.pending_deposit_sats(user_id) == 1_000_000_000
assert txid in active.asked_for # it was recognised as a coinbase from the raw transaction
one_short = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 2)
await one_short.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
mature = _listener(session_factory, active, _same([entry]), tip=100 + COINBASE_MATURITY - 1)
await mature.refresh_user(user_id, "sh") # exactly 120 confirmations
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_a_coinbase_is_not_mistaken_for_a_plain_deposit_when_the_server_cannot_say(session_factory):
"""If the raw transaction cannot be fetched, or hashes to something else than the txid,
the output waits — a server cannot make an immature coinbase look mature by lying."""
user_id = await _user(session_factory)
tx, other = _coinbase_tx(), _plain_tx(9)
entry = _entry(tx, height=100)
missing = _Active([entry], {}) # KeyError on get_transaction
await _listener(session_factory, missing, _same([entry]), tip=160).refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
swapped = _Active([entry], {tx.txid().hex(): other.serialize().hex()}) # a different transaction
listener = _listener(session_factory, swapped, _same([entry]), tip=160)
assert await listener.is_coinbase(tx.txid().hex()) is None
await listener.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
async def test_an_output_of_a_platform_transaction_is_credited_at_one_confirmation(session_factory):
"""The change of a bet, a withdrawal or a payout: the platform built the transaction, so
nobody can double-spend its inputs — including a version replaced by an RBF bump."""
user_id = await _user(session_factory)
current, replaced = _plain_tx(2), _plain_tx(3)
async with session_factory() as session:
row = PendingTransaction(
kind="bet", user_id=user_id, current_txid=current.txid().hex(), fee_rate_sat_vb=1,
raw_tx_hex=current.serialize().hex(), status="pending",
)
session.add(row)
await session.flush()
session.add(PendingTransactionTxid(pending_transaction_id=row.id, txid=replaced.txid().hex(), raw_hex="00"))
await session.commit()
entries = [_entry(current, height=100), _entry(replaced, height=100)]
active = _Active(entries, {})
await _listener(session_factory, active, _same(entries), tip=100).refresh_user(user_id, "sh") # 1 conf
assert active.asked_for == [] # no coinbase lookup either: a platform output is not a deposit
assert await _balance(session_factory, user_id) == 2_000_000_000
async def test_dust_is_never_a_waiting_deposit(session_factory):
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100, value=293)
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 0 and await _balance(session_factory, user_id) == 0
# --- the quorum counts confirmations on our tip (ELC-17, DEP-04) -------------------------------------
async def test_the_other_servers_must_place_the_coin_deep_enough_too(session_factory):
"""Our active server says 6 confirmations; two others put the same coin one block higher
(5 confirmations on our tip): the credit is withheld, not lost."""
user_id = await _user(session_factory)
tx = _plain_tx()
raws = {tx.txid().hex(): tx.serialize().hex()}
ours = _entry(tx, height=100)
shallower = _entry(tx, height=101)
listener = _listener(session_factory, _Active([ours], raws), _same([shallower]), tip=105)
await listener.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 0
agreeing = _listener(session_factory, _Active([ours], raws), _same([ours]), tip=105)
await agreeing.refresh_user(user_id, "sh")
assert await _balance(session_factory, user_id) == 1_000_000_000
async def test_corroborate_utxo_credit_compares_heights_on_our_tip(session_factory):
listener = _listener(session_factory, _Active([]), _same([{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 5}]), tip=105)
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=6) is True
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5, required_confirmations=7) is False
# without a requirement only "confirmed" matters, as before
assert await listener.corroborate_utxo_credit("sh", "aa" * 32, 0, 5) is True
# --- classification and the displayed balance (DEP-12 (b), DEP-14) -----------------------------------
async def test_classify_new_deposits_splits_credit_from_waiting():
async def never(_txid):
raise AssertionError("no coinbase lookup expected")
entries = [
{"tx_hash": "01" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # platform, 1 conf
{"tx_hash": "02" * 32, "tx_pos": 0, "height": 100, "value": 1_000}, # deposit, 1 conf: waits
{"tx_hash": "03" * 32, "tx_pos": 0, "height": 96, "value": 1_000}, # deposit, 5 confs: waits
{"tx_hash": "04" * 32, "tx_pos": 0, "height": 500, "value": 1_000}, # not on our chain yet: waits
]
policy = DepositPolicy(deposit_confirmations=6, platform_txids={"01" * 32})
credit, waiting = await classify_new_deposits(entries, 100, policy, never)
assert [(e["tx_hash"], required) for e, required in credit] == [("01" * 32, 1)]
assert [e["tx_hash"] for e in waiting] == ["02" * 32, "03" * 32, "04" * 32]
async def test_the_displayed_balance_includes_waiting_deposits_but_the_spendable_one_does_not(session_factory):
user_id = await _user(session_factory)
async with session_factory() as session:
user = await session.get(User, user_id)
assert await compute_pending_balance(session, user) == (0, False)
shown, has_pending = await compute_pending_balance(session, user, pending_deposit_sats=7_000)
assert (shown, has_pending) == (7_000, True)
assert user.cached_balance_sats == 0 # DEP-13: never spendable until credited
async def test_a_waiting_deposit_that_vanishes_stops_being_shown(session_factory):
"""DEP-14: the set is replaced at every refresh, so a deposit dropped by a reorganisation
is no longer shown."""
user_id = await _user(session_factory)
tx = _plain_tx()
entry = _entry(tx, height=100)
listener = _listener(session_factory, _Active([entry]), _same([entry]), tip=100)
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 1_000_000_000
listener.client = _Active([]) # gone from the chain
await listener.refresh_user(user_id, "sh")
assert listener.pending_deposit_sats(user_id) == 0
async def test_new_blocks_refresh_the_users_that_have_a_waiting_deposit(session_factory, monkeypatch):
"""No scripthash notification announces that a deposit matured (its history did not
change), so each block refreshes only the users with a waiting deposit, one task at a time."""
import asyncio
listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS)
listener.client = object()
listener._scripthash_to_user = {"sh1": 1, "sh2": 2}
listener._pending_deposits = {1: [{"value": 5}]}
started: list[int] = []
release = asyncio.Event()
async def fake_refresh(user_id, scripthash):
started.append(user_id)
await release.wait()
monkeypatch.setattr(listener, "refresh_user", fake_refresh)
listener._refresh_waiting_deposits()
listener._refresh_waiting_deposits() # a second block while the first refresh is still running
await asyncio.sleep(0)
assert started == [1] # user 2 has nothing waiting; user 1 is not refreshed twice at once
release.set()
await asyncio.sleep(0.01)
listener._refresh_waiting_deposits()
await asyncio.sleep(0)
assert started == [1, 1]
async def test_find_new_credit_candidates_still_lists_every_new_confirmed_output(session_factory):
"""The read half is unchanged: thresholds are applied by classify_new_deposits, between
the read and the write (no session open across the coinbase lookup, B-18)."""
user_id = await _user(session_factory)
entries = [{"tx_hash": "05" * 32, "tx_pos": 0, "height": 100, "value": 1_000}]
async with session_factory() as session:
assert await find_new_credit_candidates(session, user_id, entries) == entries
assert (await session.scalars(select(UtxoEvent))).all() == []
assert (await session.scalars(select(AuditLog))).all() == []
+4
View File
@@ -405,6 +405,7 @@ async def test_refresh_user_marks_a_utxo_spent_once_others_corroborate_it(sessio
}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(_UNRELATED_ENTRY) # our own connection no longer sees the UTXO either
await listener.refresh_user(user_id, "scripthash")
@@ -431,6 +432,7 @@ async def test_refresh_user_does_not_mark_when_corroboration_fails(session_facto
{"first.example": [], "second.example": still_there, "third.example": still_there}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(_UNRELATED_ENTRY)
await listener.refresh_user(user_id, "scripthash")
@@ -719,6 +721,7 @@ async def test_refresh_user_does_not_credit_a_utxo_only_our_own_server_reports(s
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
# Our own connection reports the tracked UTXO plus a phantom one nobody else has.
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
)
@@ -742,6 +745,7 @@ async def test_refresh_user_credits_once_the_others_corroborate(session_factory)
{"first.example": [], "second.example": _PHANTOM, "third.example": _PHANTOM}
)
listener = ElectrumListener(others_factory, session_factory, _ENDPOINTS)
listener.tip_height = 1000 # B-82: the entries below are all far past every threshold, coinbase maturity included
listener.client = _ActiveClient(
[{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] + _PHANTOM
)
+10
View File
@@ -203,3 +203,13 @@ def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): #
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
def test_deposit_confirmations_and_payout_blocked_columns_exist_at_head(migrated_db): # B-82 (DAT-62, DAT-68)
with sqlite3.connect(migrated_db) as conn:
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
assert config["deposit_confirmations"][3] == 1 and config["deposit_confirmations"][4] in ("6", "'6'")
assert "payout_blocked_at" in rounds and rounds["payout_blocked_at"][3] == 0
assert _index_sql(migrated_db, "ix_rounds_single_active") is not None
assert _index_sql(migrated_db, "ix_users_username_lower") is not None
+319
View File
@@ -0,0 +1,319 @@
"""B-82 (RND-22, RND-39, DAT-11, DAT-67, DAT-68, ELC-27, AUD-44, AUD-45): the coins of a round
are verified with the quorum before a payout is built.
A user can bet and then — with a private chain, or just by being unlucky in a reorganisation —
get the deposit that funded the bet erased. The bet leaves the chain but its participant stayed
`confirmed`, so the payout looked for coins that no longer existed and failed "insufficient pool
UTXOs" for ever, with every other player's money stuck. Now the bets that are gone leave the
round, the 70/30 split is computed on what is really there, and a winner whose own bet is gone
holds the payout and pauses the lottery.
"""
import pytest
from embit.transaction import Transaction
from sqlalchemy import select
from app.config import MIN_QUORUM_RESPONSES
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User
from app.electrum.client import ElectrumEndpoint
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.scheduler import RoundScheduler
from app.rounds.service import payout_config_problem, winner_share
from app.wallet.hd import derive_pool_address
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import (
DUST_LIMIT_SATS,
MAX_PARTICIPANTS_PER_ROUND,
MAX_TX_INPUTS,
estimate_vsize,
payout_bump_reserve,
)
from tests.unit.test_scheduler import ( # noqa: F401 (fixture and helpers)
_FEE_ADDRESS,
_WINNER_ADDRESS,
FakePayoutClient,
FakePayoutListener,
_payout_bet_txid as _bet_txid,
_seed_paying_out_round,
payout_session_factory,
)
_BET = 999_000_000 # the net amount of a 10 PLM bet, roughly
def _scheduler(session_factory, listener=None):
listener = listener or FakePayoutListener(FakePayoutClient([]))
return RoundScheduler(session_factory, listener), listener
async def _state(session_factory):
async with session_factory() as session:
round_ = await session.get(Round, 1)
participants = {
p.bet_txid: p.status for p in (await session.scalars(select(RoundParticipant))).all()
}
events = [(e.event_type, e.payload_json) for e in (await session.scalars(select(AuditLog).order_by(AuditLog.id))).all()]
pending = (await session.scalars(select(PendingTransaction))).all()
config = await session.scalar(select(RoundConfig))
return round_, participants, events, pending, config
def _names(events):
return [name for name, _ in events]
# --- everything is there -------------------------------------------------------------------------------
async def test_the_payout_spends_exactly_the_verified_bet_outputs_and_nothing_else(payout_session_factory):
"""RND-22: other coins at the pool address are not touched — the active connection is not
even asked for the address' coins, and the inputs are the participants' `bet_txid:bet_vout`."""
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
stray = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 10, "value": 5_000_000_000}]
listener.client = FakePayoutClient(stray)
await scheduler._trigger_payout(1)
assert listener.client.broadcasted
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert sorted((v.txid.hex(), v.vout) for v in tx.vin) == sorted((_bet_txid(i), 0) for i in range(3))
assert listener.verified_scripthashes == [address_to_scripthash(derive_pool_address(1))]
assert [(t, v, a) for t, v, a in listener.verified_outpoints[0]] == [(_bet_txid(i), 0, _BET) for i in range(3)]
round_, participants, events, pending, _ = await _state(payout_session_factory)
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 3 * _BET
assert "participant_reorged_out" not in _names(events) and "payout_blocked" not in _names(events)
# --- a bet that is gone (RND-39, DAT-67, AUD-45) ----------------------------------------------------------
async def test_a_reorged_out_bet_is_excluded_and_the_split_is_computed_on_what_is_there(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET + 1, _BET + 2, _BET + 3])
scheduler, listener = _scheduler(payout_session_factory)
gone = (_bet_txid(2), 0) # a loser's bet, double-spent away
listener.pool_absent = {gone}
await scheduler._trigger_payout(1)
round_, participants, events, pending, _ = await _state(payout_session_factory)
remaining = _BET + (_BET + 1) + (_BET + 3)
assert participants[gone[0]] == "reorged_out"
assert [s for t, s in participants.items() if t != gone[0]] == ["confirmed"] * 3
assert round_.pool_amount_sats == remaining # DAT-67: rewritten, and only ever reduced
row = next(p for n, p in events if n == "participant_reorged_out")
assert f'"txid": "{gone[0]}"' in row and f'"pool_amount_sats": {remaining}' in row
# the transaction spends the three bets that are there, and splits exactly their sum
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert gone not in {(v.txid.hex(), v.vout) for v in tx.vin}
assert len(tx.vin) == 3
fee = estimate_vsize(3, 3)
reserve = payout_bump_reserve(3)
assert tx.vout[0].value == winner_share(remaining) - fee == round_.winner_amount_sats
assert tx.vout[1].value == remaining - winner_share(remaining) - reserve == round_.fee_amount_sats
assert tx.vout[2].value == reserve == round_.bump_reserve_sats
assert sum(o.value for o in tx.vout) + fee == remaining
async def test_several_bets_gone_at_once_each_get_a_row_in_one_transaction(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET] * 5)
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(1), 0), (_bet_txid(4), 0)}
await scheduler._trigger_payout(1)
round_, participants, events, _, _ = await _state(payout_session_factory)
assert sorted(t for t, s in participants.items() if s == "reorged_out") == sorted([_bet_txid(1), _bet_txid(4)])
assert _names(events).count("participant_reorged_out") == 2
assert round_.pool_amount_sats == 3 * _BET
# --- the winner's own bet is gone (DAT-68, AUD-44) -----------------------------------------------------------
async def test_the_winners_own_bet_gone_holds_the_payout_and_pauses_the_lottery(payout_session_factory):
winner_id = await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0), (_bet_txid(2), 0)} # the winner's, and one more
await scheduler._trigger_payout(1)
round_, participants, events, pending, config = await _state(payout_session_factory)
assert listener.client.broadcasted == [] and pending == [] # nothing built, nothing sent
assert round_.payout_blocked_at is not None and round_.status == "paying_out"
assert (config.paused, config.paused_reason) == (True, "payout_blocked")
names = _names(events)
assert "payout_blocked" in names and "payout_failed" not in names # not a failure, not a retry clock
assert ("lottery_paused", '{"reason": "payout_blocked"}') in events
# the other missing bet is excluded now; the winner's stays `confirmed` so that it is
# verified again after the resume (OPS-30)
assert participants[_bet_txid(2)] == "reorged_out" and participants[_bet_txid(0)] == "confirmed"
blocked = next(p for n, p in events if n == "payout_blocked")
assert f'"txid": "{_bet_txid(0)}"' in blocked
async def test_a_held_payout_is_not_attempted_again_until_the_lottery_is_resumed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0)}
await scheduler._trigger_payout(1)
asked = len(listener.verified_scripthashes)
for _ in range(3): # three scheduler steps over the held round
await scheduler._retry_payout_if_due(1)
await scheduler._trigger_payout(1)
assert len(listener.verified_scripthashes) == asked # no verification, no build, no failure rows
_, _, events, pending, _ = await _state(payout_session_factory)
assert pending == [] and "payout_failed" not in _names(events)
@pytest.mark.parametrize("still_gone", [True, False])
async def test_after_the_resume_the_verification_repeats(payout_session_factory, still_gone):
"""DAT-68: the operator's resume clears the mark; the next attempt verifies again. If the
bet is still gone the payout is held and the lottery paused again (a new row); if it is
back, the payout goes out."""
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_absent = {(_bet_txid(0), 0)}
await scheduler._trigger_payout(1)
async with payout_session_factory() as session: # what POST /admin/resume does
round_ = await session.get(Round, 1)
round_.payout_blocked_at = None
(await session.scalar(select(RoundConfig))).paused = False
await session.commit()
if not still_gone:
listener.pool_absent = set()
await scheduler._trigger_payout(1)
round_, _, events, pending, config = await _state(payout_session_factory)
if still_gone:
assert _names(events).count("payout_blocked") == 2
assert round_.payout_blocked_at is not None and config.paused is True and pending == []
else:
assert round_.payout_blocked_at is None and len(pending) == 1 and "payout_sent" in _names(events)
# --- no quorum (RND-25, ELC-27) ------------------------------------------------------------------------------
async def test_without_a_quorum_nothing_changes_and_the_attempt_is_a_payout_failed(payout_session_factory):
await _seed_paying_out_round(payout_session_factory, bets=[_BET, _BET])
scheduler, listener = _scheduler(payout_session_factory)
listener.pool_unavailable = True
await scheduler._trigger_payout(1)
round_, participants, events, pending, config = await _state(payout_session_factory)
assert set(participants.values()) == {"confirmed"} and round_.pool_amount_sats == 2 * _BET
assert pending == [] and listener.client.broadcasted == []
assert [p for n, p in events if n == "payout_failed"] and "pool verification unavailable" in dict(events)["payout_failed"]
assert config.paused is False and round_.payout_blocked_at is None
# --- the extremes of RND-40, through the scheduler ----------------------------------------------------------------
async def test_a_round_of_one_participant_at_the_smallest_viable_bet_is_paid(payout_session_factory):
"""The smallest net bet RND-40 allows (the config's own bound) still yields a payout whose
three outputs are all at least dust."""
bet = next(b for b in range(10_000, 200_000) if payout_config_problem(b + estimate_vsize(MAX_TX_INPUTS, 2), 1) is None)
await _seed_paying_out_round(payout_session_factory, bets=[bet])
scheduler, listener = _scheduler(payout_session_factory)
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert len(tx.vin) == 1 and len(tx.vout) == 3
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert sum(o.value for o in tx.vout) + estimate_vsize(1, 3) == bet
async def test_a_full_round_is_paid_with_the_reserve_fee_and_dust_all_covered(payout_session_factory):
bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2)
await _seed_paying_out_round(payout_session_factory, bets=[bet] * MAX_PARTICIPANTS_PER_ROUND)
scheduler, listener = _scheduler(payout_session_factory)
await scheduler._trigger_payout(1)
tx = Transaction.parse(bytes.fromhex(listener.client.broadcasted[0]))
assert len(tx.vin) == MAX_PARTICIPANTS_PER_ROUND and len(tx.vout) == 3
assert tx.vout[2].value == payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND)
assert all(o.value >= DUST_LIMIT_SATS for o in tx.vout)
assert sum(o.value for o in tx.vout) + estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) == bet * MAX_PARTICIPANTS_PER_ROUND
# --- the quorum's verdict itself (ELC-27) -------------------------------------------------------------------------------
_ENDPOINTS = [
ElectrumEndpoint("first.example", 50002, True),
ElectrumEndpoint("second.example", 50002, True),
ElectrumEndpoint("third.example", 50002, True),
ElectrumEndpoint("fourth.example", 50002, True),
]
_OUTPOINT = ("aa" * 32, 0, 1_000)
class _Server:
def __init__(self, entries):
self._entries = entries
async def connect(self):
if self._entries is None:
raise ConnectionRefusedError("down")
async def listunspent(self, scripthash):
return self._entries
async def close(self):
pass
def _listener(answers: dict[str, object], session_factory=None):
servers = {host: _Server(entries) for host, entries in answers.items()}
return ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS)
def _listed(value=1_000, height=50):
return [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": height, "value": value}]
async def test_a_strict_majority_decides_each_outpoint():
# first.example is the active endpoint (never asked); second, third, fourth vote
present = _listener({"second.example": _listed(), "third.example": _listed(), "fourth.example": []})
assert await present.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): True}
absent = _listener({"second.example": _listed(), "third.example": [], "fourth.example": []})
assert await absent.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
async def test_another_amount_counts_as_absent_and_a_mempool_coin_as_neither():
other_amount = _listener({h: _listed(value=999) for h in ("second.example", "third.example", "fourth.example")})
assert await other_amount.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
unconfirmed = _listener({h: _listed(height=0) for h in ("second.example", "third.example", "fourth.example")})
assert await unconfirmed.verify_pool_outpoints("sh", [_OUTPOINT]) is None # may be mined again: no verdict
async def test_too_few_answers_or_a_tie_conclude_nothing():
assert MIN_QUORUM_RESPONSES == 2
one_answer = _listener({"second.example": [], "third.example": None, "fourth.example": None})
assert await one_answer.verify_pool_outpoints("sh", [_OUTPOINT]) is None
tie = _listener({"second.example": _listed(), "third.example": [], "fourth.example": None})
assert await tie.verify_pool_outpoints("sh", [_OUTPOINT]) is None
# nothing at all: the active server is never a substitute for the quorum
nobody = _listener({h: None for h in ("second.example", "third.example", "fourth.example")})
assert await nobody.verify_pool_outpoints("sh", [_OUTPOINT]) is None
async def test_the_active_server_is_not_asked_and_cannot_vouch_for_a_bet():
class _Hostile(_Server):
async def listunspent(self, scripthash):
raise AssertionError("the active server must not be asked")
servers = {"first.example": _Hostile(_listed()), "second.example": _Server([]), "third.example": _Server([]), "fourth.example": _Server([])}
listener = ElectrumListener(lambda endpoint: servers[endpoint.host], None, _ENDPOINTS)
listener._endpoint_index = 0
listener.client = object()
assert await listener.verify_pool_outpoints("sh", [_OUTPOINT]) == {("aa" * 32, 0): False}
+5 -1
View File
@@ -183,7 +183,11 @@ async def _payout(payout_session_factory, pool_address: str, round_id: int = 1):
client = _RecordingPayoutClient(
[{"tx_hash": "44" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}]
)
await RoundScheduler(payout_session_factory, FakePayoutListener(client))._trigger_payout(round_id)
listener = FakePayoutListener(client)
await RoundScheduler(payout_session_factory, listener)._trigger_payout(round_id)
# B-82: a round with its own address asks the quorum (the listener), not the active
# connection, which is only asked for a round paid from the shared reserve address.
client.scripthashes = client.scripthashes + listener.verified_scripthashes
return client
+32 -12
View File
@@ -120,7 +120,15 @@ async def payout_session_factory(tmp_path, monkeypatch):
hd._account_key = None
async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
def _payout_bet_txid(index: int) -> str:
return f"{index + 0x33:064x}"
async def _seed_paying_out_round(session_factory, round_id: int = 1, bets: list[int] | None = None) -> int:
"""A round in `paying_out` whose confirmed participants — the first one is the winner —
each placed a bet of the given net amount (B-82: the payout spends exactly those bet
outputs, `bet_txid:0`, so the pool is their sum)."""
bets = bets if bets is not None else [_POOL_AMOUNT_SATS]
async with session_factory() as session:
winner = User(username="winner", password_hash="x", derivation_index=0, address=_WINNER_ADDRESS)
session.add(winner)
@@ -130,11 +138,27 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int:
Round(
id=round_id,
status="paying_out",
pool_amount_sats=_POOL_AMOUNT_SATS,
pool_amount_sats=sum(bets),
winner_user_id=winner.id,
pool_address=derive_pool_address(round_id), # B-80
)
)
await session.flush()
for index, amount in enumerate(bets):
user = winner
if index:
user = User(
username=f"bettor{index}", password_hash="x", derivation_index=index,
address=derive_pool_address(7000 + index),
)
session.add(user)
await session.flush()
session.add(
RoundParticipant(
round_id=round_id, user_id=user.id, bet_amount_sats=amount, bet_txid=_payout_bet_txid(index),
bet_vout=0, status="confirmed", draw_position=index,
)
)
await session.commit()
return winner.id
@@ -206,18 +230,14 @@ async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_in
so a round past MAX_TX_INPUTS participants could not be paid at all — the build
failed with too_many_inputs, the round stayed "paying_out" retrying every 60s,
and no new round could ever open behind it. It must now broadcast normally."""
await _seed_paying_out_round(payout_session_factory)
participants = MAX_TX_INPUTS + 1
bet_sats = _POOL_AMOUNT_SATS // participants
entries = [
{"tx_hash": f"{i:064x}", "tx_pos": 0, "height": 10, "value": bet_sats}
for i in range(participants)
]
# The pool's total must cover the round's recorded pool_amount_sats, exactly as
# on-chain: integer division above leaves a remainder, so top the last one up.
entries[-1]["value"] += _POOL_AMOUNT_SATS - bet_sats * participants
client = FakePayoutClient(entries)
bets = [bet_sats] * participants
# The pool's total is the round's recorded pool_amount_sats: integer division above
# leaves a remainder, so top the last one up.
bets[-1] += _POOL_AMOUNT_SATS - bet_sats * participants
await _seed_paying_out_round(payout_session_factory, bets=bets)
client = FakePayoutClient([])
scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client))
await scheduler._trigger_payout(1)
+5 -1
View File
@@ -671,7 +671,11 @@ def _payout_scheduler(session_factory, client):
from app.rounds.scheduler import RoundScheduler
from tests.unit.test_scheduler import FakePayoutListener
return RoundScheduler(session_factory, FakePayoutListener(client))
listener = FakePayoutListener(client)
# B-82: the network phase of a payout is the quorum's verification of the round's coins,
# so that is where a test injects "the lock is lost while the network is being used".
listener.on_verify = getattr(client, "_hook_listunspent", None)
return RoundScheduler(session_factory, listener)
class _PayoutClient:
+13
View File
@@ -176,3 +176,16 @@ async def test_me_created_at_is_utc_stamped(client):
assert resp.status_code == 200
created_at = resp.json()["created_at"]
assert created_at.endswith("+00:00") or created_at.endswith("Z")
async def test_me_shows_waiting_deposits_in_the_pending_balance_only(client): # B-82 (DEP-12 (b), DEP-14)
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
me = (await client.get("/users/me", headers=headers)).json()
assert (me["balance_sats"], me["pending_balance_sats"], me["has_pending"]) == (0, 0, False)
listener = client._transport.app.state.electrum_listener
listener._pending_deposits[me["id"]] = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 5, "value": 250_000_000}]
shown = (await client.get("/users/me", headers=headers)).json()
assert shown["balance_sats"] == 0 # not spendable
assert shown["pending_balance_sats"] == 250_000_000 and shown["has_pending"] is True