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:
1 parent
daa85cf4f0
commit
54dbffb552
42 files changed
+1360
-270
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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': '存款地址',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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})")
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user