From fd6a91e93d61029b8da369a694b431fc3951e6c3 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 14 Oct 2026 13:52:32 +0200 Subject: [PATCH] Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36) All bets of all rounds paid one address (m/84'/746'/0'/1/0), so its history grew forever, other rounds' money and stray coins mixed with a round's own, and the payout picked from everything it held. - hd.py: derive_pool_key/derive_pool_address(round_id) on branch 2 (m/84'/746'/0'/2/); no argument is the reserve address 1/0. derive_round_pool_key checks the key against the address stored on the round, so a payout can never be signed for money its key does not control. - rounds.pool_address (migration 477bddae1a84, existing rounds backfilled with the reserve address since that is where their bets went; add_column, not a batch alter, so the expression indexes stay): written in the transaction that inserts the round, once its id exists. - place_bet pays round.pool_address; the payout reads, signs and takes change at the round's own address; own_address_for and bump_fee's _signing_context follow the round (confirmation poller and reconciler pass round_id). - Withdrawals to any round pool address, the reserve address or fee_address are refused (withdrawal_to_platform_address, 400, all seven languages). - GET /rounds/current and GET /admin/rounds expose pool_address. - RND-22/DAT-11 stay "da implementare" for B-82: the payout still spends every confirmed coin at the round's address, not only the participants' outpoints (participants carry no bet_vout yet). - tests/unit/conftest.py: every test now gets a throwaway master key. Opening a round derives an address, and tests that merely opened a round were silently reading the real key from the local .env. - Existing tests changed because the spec changed: payout fixtures give their round a pool_address; the reconciler's address-cache test uses two payouts of one round (rows of different rounds no longer share an address); own_address_for now takes the round id. Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 11 - CLAUDE.md | 8 +- README.md | 2 +- app/api/routes/admin.py | 2 + app/api/routes/rounds.py | 4 + app/bets/service.py | 9 +- app/db/models.py | 6 + app/rounds/scheduler.py | 8 +- app/rounds/service.py | 4 + app/static/i18n.js | 7 + app/tx/broadcast.py | 16 +- app/tx/confirmation.py | 11 +- app/tx/pending_address.py | 16 +- app/tx/reconcile.py | 6 +- app/wallet/hd.py | 39 ++- app/withdrawals/service.py | 20 +- ...i-round-ha-proprio-indirizzo-montepremi.md | 11 +- docs/api.md | 17 +- docs/architettura.md | 2 +- docs/ciclo-di-vita-del-round.md | 20 +- docs/limiti-noti.md | 14 +- docs/modello-dati.md | 40 ++- docs/operativita.md | 17 +- docs/registrazione-e-depositi.md | 6 +- docs/scommesse-e-prelievi.md | 14 +- docs/sicurezza.md | 16 +- docs/storico-bug.md | 6 +- docs/transazioni-e-riconciliazione.md | 7 +- ...477bddae1a84_add_pool_address_to_rounds.py | 55 +++ tests/unit/conftest.py | 25 ++ tests/unit/test_admin.py | 17 + tests/unit/test_confirmation.py | 6 +- tests/unit/test_pending_address.py | 26 +- tests/unit/test_reconcile.py | 9 +- tests/unit/test_round_pool_address.py | 319 ++++++++++++++++++ tests/unit/test_rounds_route.py | 19 ++ tests/unit/test_scheduler.py | 2 + 37 files changed, 673 insertions(+), 144 deletions(-) create mode 100644 migrations/versions/477bddae1a84_add_pool_address_to_rounds.py create mode 100644 tests/unit/conftest.py create mode 100644 tests/unit/test_round_pool_address.py diff --git a/BUGS.md b/BUGS.md index 04ed1c9..10c9e14 100644 --- a/BUGS.md +++ b/BUGS.md @@ -199,17 +199,6 @@ round of 1 or of `MAX_PARTICIPANTS_PER_ROUND` participants could not cover winne fee + reserve + dust commission, and `PUT /admin/config` refuses a bet amount or fee rate that would fail the same check (spec: RND-40). -### B-80 — One pool address for every round - -All bets of all rounds pay one address: its history grows forever (B-75), stray -money and other rounds' money mix with this round's, and a restored backup replays a -round against whatever the pool holds now. - -Fix: each round gets its own pool address `m/84'/746'/0'/2/`, stored on -the round (`rounds.pool_address`); bets pay it, the payout spends only its UTXOs. -Rounds opened before this change keep the old address. Withdrawals to any pool, -reserve or fee address are refused. - ### B-81 — `drawing` wedges on any exception, not only on restart The participant snapshot and `tip_at_close` live only in memory; `_tick` skips diff --git a/CLAUDE.md b/CLAUDE.md index d93ae08..7e075d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ 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 — 396 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 — 414 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. @@ -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 396 tests +python -m pytest # all 414 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 ``` @@ -127,7 +127,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW | `app/api/routes/` | `admin`, `bets`, `withdrawals`, `rounds` (incl. SSE), `users`, `qr`, `bug_reports`; `app/api/errors.py` holds the error contract and `app/api/client_ip.py` the trusted-peer extraction every IP-keyed control uses | | `app/auth/` | routes (register/login), Argon2 + JWT (`security.py`), `get_current_user`/`get_optional_user`, login/registration throttling (`rate_limit.py`) | | `app/db/` | `models.py` (all tables + the active-round index), engine/session factories | -| `app/wallet/` | HD derivation + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) | +| `app/wallet/` | HD derivation (users `0/i`, per-round pool `2/`, reserve `1/0`) + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) | | `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) | | `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) | | `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers | @@ -176,7 +176,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): first block confirmed after closing — corroborated by the other servers first, and on failure the draw waits for a *further* block and writes a `draw_header_corroboration_failed` audit entry rather than stalling silently — hash as seed, `index = seed mod participant_count` over participants ordered by **broadcast timestamp** (also the tie-break when two bets land in the same block). Equal probability for everyone, regardless of amount. The baseline the draw compares against (`tip_at_close`) must be a height we actually knew at closing time: a `0` there means *unknown*, not "the chain is at zero", so `_wait_for_next_block` adopts the first height it then learns as the baseline and waits for a block strictly after it (`draw_baseline_tip_unknown`, B-63) — seeding from a block that already existed while bets were open would make the winner predictable to whoever was watching the chain. -- *Payout* is signed with the pool key; its **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern. +- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern. - *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal. - *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`. diff --git a/README.md b/README.md index 39c4a85..8b67e14 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -396 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +414 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index c515e7c..e57fbf3 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -241,6 +241,7 @@ class AdminRoundResponse(BaseModel): winner_amount_sats: int | None fee_amount_sats: int | None payout_txid: str | None + pool_address: str | None # B-80 @router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) @@ -268,6 +269,7 @@ async def list_rounds( winner_amount_sats=r.winner_amount_sats, fee_amount_sats=r.fee_amount_sats, payout_txid=r.payout_txid, + pool_address=r.pool_address or None, ) for r in rounds ] diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index 26409f7..a92292e 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -89,6 +89,9 @@ class CurrentRoundResponse(BaseModel): server_time: str round_id: int | None = None status: str | None = None + # B-80 (RND-35): this round's own pool address, so anyone can verify its money on + # an explorer; null when there is no active round. + pool_address: str | None = None opened_at: str | None = None closes_at: str | None = None # B-65: confirmed participants only — the ones the draw actually picks from and @@ -205,6 +208,7 @@ async def current_round( server_time=datetime.now(timezone.utc).isoformat(), round_id=round_.id, status=round_.status, + pool_address=round_.pool_address or None, opened_at=opened_at.isoformat(), closes_at=closes_at.isoformat(), participant_count=participant_count, diff --git a/app/bets/service.py b/app/bets/service.py index d138f49..f575e4b 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -15,7 +15,7 @@ from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed, round_accepts_bets from app.wallet.balance import recompute_balance -from app.wallet.hd import derive_pool_address, derive_user_key +from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import ( MAX_PARTICIPANTS_PER_ROUND, BuiltTransaction, @@ -85,6 +85,11 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - max_participants=MAX_PARTICIPANTS_PER_ROUND, ) + if not round_.pool_address: + # B-80: open_new_round_if_needed always writes it; a row without one cannot + # take money that would then have nowhere verifiable to go. + raise RuntimeError(f"round {round_.id} has no pool address") + bet_amount = config.bet_amount_sats unspent = ( @@ -104,7 +109,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - signing_key=user_key, from_script=from_script, utxos=utxos, - to_address=derive_pool_address(), + to_address=round_.pool_address, # B-80: this round's own pool address amount_sats=bet_amount, change_address=user.address, fee_rate_sat_vb=config.fee_rate_sat_vb, diff --git a/app/db/models.py b/app/db/models.py index 833b6f9..fa31a2b 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -92,6 +92,12 @@ class Round(Base): # the gap it announced is the gap that's honoured. duration_seconds: Mapped[int] = mapped_column(default=600, server_default="600") cooldown_seconds: Mapped[int] = mapped_column(default=30, server_default="30") + # B-80 (DAT-60, RND-35): this round's own pool address, m/84'/746'/0'/2/. The id + # only exists once the row is flushed, so the row is inserted with "" and the real + # address is written in the same transaction (rounds/service.py:open_new_round_if_needed) + # — "" is never visible outside it, and every consumer refuses it. Rounds opened before + # B-80 hold the reserve address (m/84'/746'/0'/1/0), their old shared pool. + pool_address: Mapped[str] = mapped_column(String(128), default="", server_default="") # Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw). # Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current # (draw_waiting_since) measure how long a round has been waiting on a block, diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index 4add837..48644ae 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -17,7 +17,7 @@ from app.rounds.config import get_round_config from app.rounds.draw import draw_winner, header_hex_to_block_hash from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share -from app.wallet.hd import derive_pool_key +from app.wallet.hd import derive_round_pool_key from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction @@ -400,6 +400,7 @@ class RoundScheduler: fee_address = config.fee_address fee_rate = config.fee_rate_sat_vb pool_amount_sats = round_.pool_amount_sats + round_pool_address = round_.pool_address # B-80 ("" fails in derive_round_pool_key below) winner_user_id = round_.winner_user_id winner = await session.get(User, winner_user_id) winner_address = winner.address if winner is not None else None @@ -421,7 +422,10 @@ class RoundScheduler: # --- Phase 2: build (network read only, no DB write yet) ----------------- try: - pool_key = derive_pool_key() + # B-80 (RND-22): the round's own key and address — only the coins at this + # round's pool address are ever read, so no other round's money (and no + # stray coin sent to another address) can end up among the inputs. + pool_key = derive_round_pool_key(round_id, round_pool_address) pool_script_obj = script.p2wpkh(pool_key.to_public()) pool_address = pool_script_obj.address(network=PLM_MAINNET) entries = await client.listunspent(address_to_scripthash(pool_address)) diff --git a/app/rounds/service.py b/app/rounds/service.py index c9eaf03..3ba9566 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Round, RoundConfig from app.rounds.config import get_round_config from app.rounds.events import broadcaster +from app.wallet.hd import derive_pool_address logger = logging.getLogger(__name__) @@ -152,6 +153,9 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None: # only tells subscribers "go refetch", and by the time an SSE client's # refetch request actually lands, this in-process commit (microseconds # away) has essentially always already happened. + # B-80 (RND-35, DAT-60): the round's own pool address, now that its id exists — + # still inside the caller's transaction, so no committed round ever lacks one. + round_.pool_address = derive_pool_address(round_.id) broadcaster.publish() return round_ diff --git a/app/static/i18n.js b/app/static/i18n.js index df848d1..4061867 100644 --- a/app/static/i18n.js +++ b/app/static/i18n.js @@ -163,6 +163,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': 'The amount is too small to be sent.', 'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.', 'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.', + 'error.withdrawal_to_platform_address': 'That is an address of this platform — withdraw to an external wallet.', 'error.internal_error': 'Unexpected server error. Please try again shortly.', 'error.guide_unavailable': 'The guide is not available right now.', 'error.rate_limited': 'Too many attempts, please try again in {retry_after_seconds} seconds.', @@ -329,6 +330,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.", 'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.', 'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.', + 'error.withdrawal_to_platform_address': 'Questo è un indirizzo della piattaforma — preleva verso un wallet esterno.', 'error.internal_error': 'Errore inatteso del server. Riprova tra poco.', 'error.guide_unavailable': 'La guida non è disponibile in questo momento.', 'error.rate_limited': 'Troppi tentativi, riprova tra {retry_after_seconds} secondi.', @@ -495,6 +497,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.', 'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.', 'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.', + 'error.withdrawal_to_platform_address': 'Esa es una dirección de la propia plataforma — retira a una cartera externa.', 'error.internal_error': 'Error inesperado del servidor. Inténtalo de nuevo en un momento.', 'error.guide_unavailable': 'La guía no está disponible en este momento.', 'error.rate_limited': 'Demasiados intentos, inténtalo de nuevo en {retry_after_seconds} segundos.', @@ -661,6 +664,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.", 'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.', 'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.", + 'error.withdrawal_to_platform_address': "C'est une adresse de la plateforme elle-même — retirez vers un portefeuille externe.", 'error.internal_error': 'Erreur inattendue du serveur. Veuillez réessayer dans un instant.', 'error.guide_unavailable': "Le guide n'est pas disponible pour le moment.", 'error.rate_limited': 'Trop de tentatives, réessayez dans {retry_after_seconds} secondes.', @@ -827,6 +831,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.', 'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.', 'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.', + 'error.withdrawal_to_platform_address': 'Das ist eine Adresse der Plattform selbst — zahle auf eine externe Wallet aus.', 'error.internal_error': 'Unerwarteter Serverfehler. Bitte versuche es in Kürze erneut.', 'error.guide_unavailable': 'Die Anleitung ist derzeit nicht verfügbar.', 'error.rate_limited': 'Zu viele Versuche, bitte versuche es in {retry_after_seconds} Sekunden erneut.', @@ -993,6 +998,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.', 'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.', 'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.', + 'error.withdrawal_to_platform_address': 'Это адрес самой платформы — выводите на внешний кошелёк.', 'error.internal_error': 'Непредвиденная ошибка сервера. Попробуйте ещё раз через минуту.', 'error.guide_unavailable': 'Руководство сейчас недоступно.', 'error.rate_limited': 'Слишком много попыток, повторите через {retry_after_seconds} сек.', @@ -1159,6 +1165,7 @@ const TRANSLATIONS = { 'error.amount_below_dust_limit': '金额过小,无法发送。', 'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。', 'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。', + 'error.withdrawal_to_platform_address': '这是平台自己的地址 — 请提现到外部钱包。', 'error.internal_error': '服务器发生意外错误,请稍后重试。', 'error.guide_unavailable': '指南当前不可用。', 'error.rate_limited': '尝试次数过多,请在 {retry_after_seconds} 秒后重试。', diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index 5dd494c..294e49c 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -13,7 +13,7 @@ from app.db.base import begin_immediate from app.db.models import PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config -from app.wallet.hd import derive_pool_key, derive_user_key +from app.wallet.hd import derive_round_pool_key, derive_user_key from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB, RBF_SEQUENCE, estimate_vsize @@ -49,12 +49,18 @@ def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int return now >= pending.last_broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) -async def _signing_context(session: AsyncSession, kind: str, user_id: int | None) -> tuple: +async def _signing_context( + session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None +) -> tuple: """Returns (signing_key, own_script, own_address) for the single sender that controls every input of this tx — a user for bet/withdrawal, the pool for - payout. All our builders only ever spend one address's UTXOs per tx.""" + payout (the round's own pool key, B-80). All our builders only ever spend one + address's UTXOs per tx.""" if kind == "payout": - key = derive_pool_key() + round_ = await session.get(Round, round_id) if round_id is not None else None + if round_ is None or not round_.pool_address: + raise RbfError(f"payout of round {round_id}: the round has no pool address") + key = derive_round_pool_key(round_.id, round_.pool_address) else: user = await session.get(User, user_id) key = derive_user_key(user.derivation_index) @@ -125,7 +131,7 @@ async def bump_fee( kind = pending.kind current_fee_rate = pending.fee_rate_sat_vb raw_tx_hex = pending.raw_tx_hex - signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id) + signing_key, own_script, own_address = await _signing_context(session, kind, pending.user_id, pending.round_id) # --- Phase 2: chain reads, signing, and the broadcast — no DB session open ---- old_tx = Transaction.parse(bytes.fromhex(raw_tx_hex)) diff --git a/app/tx/confirmation.py b/app/tx/confirmation.py index 1ac4356..99adb43 100644 --- a/app/tx/confirmation.py +++ b/app/tx/confirmation.py @@ -38,24 +38,25 @@ async def poll_once(session_factory: async_sessionmaker, client: ElectrumClient) PendingTransaction.current_txid, PendingTransaction.kind, PendingTransaction.user_id, + PendingTransaction.round_id, ).where(PendingTransaction.status == "pending") ) ).all() # Resolved once per candidate while the session is still open, and cached - # by scripthash below — every "payout" row shares the same pool address, - # so this also avoids asking the server the same history twice per tick. + # by scripthash below — the payout rows of one round share its pool + # address (B-80), so this also avoids asking the server the same history twice. scripthash_by_id: dict[int, str] = {} - for pending_id, _txid, kind, user_id in candidates: + for pending_id, _txid, kind, user_id, round_id in candidates: try: - address = await own_address_for(session, kind, user_id) + address = await own_address_for(session, kind, user_id, round_id) scripthash_by_id[pending_id] = address_to_scripthash(address) except Exception: logger.exception("could not derive the address for pending_transaction %s", pending_id) confirmed = 0 history_cache: dict[str, list[dict]] = {} - for pending_id, txid, kind, _user_id in candidates: + for pending_id, txid, kind, _user_id, _round_id in candidates: scripthash = scripthash_by_id.get(pending_id) if scripthash is None: continue # address derivation failed above; already logged diff --git a/app/tx/pending_address.py b/app/tx/pending_address.py index 6cc2cff..dedbd54 100644 --- a/app/tx/pending_address.py +++ b/app/tx/pending_address.py @@ -1,12 +1,15 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import User -from app.wallet.hd import derive_pool_address, derive_user_address +from app.db.models import Round, User +from app.wallet.hd import derive_user_address -async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) -> str: +async def own_address_for( + session: AsyncSession, kind: str, user_id: int | None, round_id: int | None = None +) -> str: """The address that owns every input of a PendingTransaction of this kind — - a user's own address for a bet/withdrawal, the pool address for a payout. + a user's own address for a bet/withdrawal, the pool address of the round for a + payout (B-80: each round has its own, stored on the round). All our builders only ever spend one address's UTXOs per tx (see tx/broadcast.py:_signing_context, which derives the same address alongside the signing key it also needs). @@ -17,6 +20,9 @@ async def own_address_for(session: AsyncSession, kind: str, user_id: int | None) different addresses for the same row. """ if kind == "payout": - return derive_pool_address() + round_ = await session.get(Round, round_id) if round_id is not None else None + if round_ is None or not round_.pool_address: + raise ValueError(f"payout of round {round_id}: the round has no pool address") + return round_.pool_address user = await session.get(User, user_id) return derive_user_address(user.derivation_index) diff --git a/app/tx/reconcile.py b/app/tx/reconcile.py index 83bcfe4..22e0019 100644 --- a/app/tx/reconcile.py +++ b/app/tx/reconcile.py @@ -87,8 +87,8 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl a decision that releases funds. A transport failure fetching history still raises and leaves the row alone until next time — get_history not returning our txid is the only thing that means "gone". History is cached - per scripthash within one pass, since every "payout" row shares the same - pool address. + per scripthash within one pass, since the payout rows of one round share its + pool address (B-80). """ now = datetime.now(timezone.utc) async with session_factory() as session: @@ -102,7 +102,7 @@ async def reconcile_once(session_factory: async_sessionmaker, client: ElectrumCl if not _is_due(row, now): continue try: - address = await own_address_for(session, row.kind, row.user_id) + address = await own_address_for(session, row.kind, row.user_id, row.round_id) scripthash = address_to_scripthash(address) except Exception: logger.exception("could not derive the address for pending_transaction %s", row.id) diff --git a/app/wallet/hd.py b/app/wallet/hd.py index 23bf961..28703a7 100644 --- a/app/wallet/hd.py +++ b/app/wallet/hd.py @@ -50,14 +50,37 @@ def derive_user_wif(derivation_index: int) -> str: return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(network=PLM_MAINNET) -def derive_pool_key() -> HDKey: - """The pool key ("indirizzo padre"): all bets are sent here, and - payouts are signed with this key. Reserved on branch 1 of the account (branch 0 - is user addresses), index 0 — not a spec requirement, an implementation choice - to keep it in the same encrypted master key rather than a separate secret.""" - return _load_account_key().derive("1/0") +def derive_pool_key(round_id: int | None = None) -> HDKey: + """A pool key. With a `round_id`, that round's own key on branch 2 of the account + (`m/84'/746'/0'/2/`, B-80, RND-35): its bets are paid to it and its + payout is signed with it, so one round's money and history never mix with another's. + Without one, the **reserve** key at branch 1, index 0 — the single pool address every + round used before B-80, still the pool of the rounds opened back then and the + destination of the payout's bump reserve. Branch 0 is users. Not a spec requirement + of the coin, an implementation choice to keep every key under the one encrypted + master key rather than in separate secrets.""" + if round_id is None: + return _load_account_key().derive("1/0") + if round_id < 1: + raise ValueError(f"invalid round id {round_id}") + return _load_account_key().derive(f"2/{round_id}") -def derive_pool_address() -> str: - pub = derive_pool_key().to_public() +def derive_pool_address(round_id: int | None = None) -> str: + """The pool address of a round (B-80), or the reserve address without a `round_id`.""" + pub = derive_pool_key(round_id).to_public() return script.p2wpkh(pub).address(network=PLM_MAINNET) + + +def derive_round_pool_key(round_id: int, pool_address: str) -> HDKey: + """The key that controls `pool_address`, the address stored on round `round_id`. + + A round opened before B-80 stores the reserve address and is signed with the reserve + key; every other round with its own. Checking the derived address against the stored + one means a key can never be used for money it does not control, and a mismatch (a + different master key, a corrupted row) fails loudly instead of building a transaction + that spends someone else's coins.""" + for key in (derive_pool_key(round_id), derive_pool_key()): + if script.p2wpkh(key.to_public()).address(network=PLM_MAINNET) == pool_address: + return key + raise ValueError(f"round {round_id}: no pool key controls the stored address {pool_address!r}") diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index 2d156a5..5906eef 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -8,13 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import ApiError from app.audit.log import write_audit_log from app.db.base import begin_immediate -from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal +from app.db.models import PendingTransaction, Round, User, UtxoEvent, Withdrawal from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.rounds.events import broadcaster from app.wallet.address import is_valid_plm_address from app.wallet.balance import compute_pending_balance, recompute_balance -from app.wallet.hd import derive_user_key +from app.wallet.hd import derive_pool_address, derive_user_key from app.wallet.psbt_builder import ( BuiltTransaction, InsufficientFundsError, @@ -63,6 +63,22 @@ async def request_withdrawal( ) from exc config = await get_round_config(session) + + # B-80 (WDR-14): never to an address of the platform itself. Coins sent to a round's + # pool address land among that round's UTXOs, and to the reserve or fee address they + # leave the user's balance for good; the amount would be spent from the balance and + # then belong to nobody. Compared lower-cased, since bech32 is case-insensitive. + platform_addresses = {derive_pool_address().lower(), config.fee_address.strip().lower()} + platform_addresses.update( + address.lower() for address in await session.scalars(select(Round.pool_address).distinct()) if address + ) + platform_addresses.discard("") + if external_address.strip().lower() in platform_addresses: + raise WithdrawalError( + "withdrawal_to_platform_address", + "that is an address of the platform itself — withdraw to an external wallet instead", + ) + if amount_sats < config.bet_amount_sats: raise WithdrawalError( "amount_below_minimum", diff --git a/docs/adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md b/docs/adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md index 66b1506..b15a72f 100644 --- a/docs/adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md +++ b/docs/adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md @@ -1,6 +1,6 @@ # ADR-03 — Ogni round ha il proprio indirizzo del montepremi -- **Stato:** Accettata — implementazione da completare (B-80) +- **Stato:** Accettata - **Area:** Custodia, chiavi e indirizzi - **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*. @@ -30,10 +30,7 @@ commissioni è rifiutato. Il ramo `0` resta riservato agli utenti. lì, visibile ma fuori da ogni pagamento; il margine fra partecipanti e ingressi del pagamento ([ADR-21](ADR-21-tre-limiti-distinti-sul-numero-monete.md)) non si consuma più nel tempo ([LIM-17]). Regole in [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento) - e [scommesse-e-prelievi.md](../scommesse-e-prelievi.md#prelievo) (B-80). + e [scommesse-e-prelievi.md](../scommesse-e-prelievi.md#prelievo). -*Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · `app/rounds/service.py:open_new_round_if_needed` · -*Test:* `tests/unit/test_pending_address.py` -*Stato:* da implementare (B-80) · *Oggi:* un unico indirizzo del montepremi -(`1/0`) riceve le scommesse di tutti i round e il pagamento sceglie fra -tutte le sue monete. +*Codice:* `app/wallet/hd.py:derive_pool_key` · `app/wallet/hd.py:derive_pool_address` · `app/wallet/hd.py:derive_round_pool_key` · `app/rounds/service.py:open_new_round_if_needed` · +*Test:* `tests/unit/test_round_pool_address.py`, `tests/unit/test_pending_address.py` diff --git a/docs/api.md b/docs/api.md index ebf9056..87fe814 100644 --- a/docs/api.md +++ b/docs/api.md @@ -504,7 +504,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. finché la rete non torna); poi `400`: `invalid_address`, `withdrawal_to_own_address`, `withdrawal_to_platform_address` (destinazione uguale a un indirizzo di montepremi, di riserva o delle - commissioni, B-80), `amount_below_minimum` (`params.minimum_sats` = costo + commissioni), `amount_below_minimum` (`params.minimum_sats` = costo attuale della scommessa; verificato anche sull'importo ridotto di un prelievo dell'intero saldo, B-96), `balance_pending_confirmation` (`params.pending_sats`), `insufficient_balance` (`params.required_sats`), @@ -521,10 +521,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. *Codice:* `app/api/routes/withdrawals.py:create_withdrawal`, `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_withdrawals.py` - *Stato:* da implementare (B-76, B-80, B-96) · *Oggi:* ogni esito diverso + *Stato:* da implementare (B-76, B-96) · *Oggi:* ogni esito diverso dal successo è `502 broadcast_failed` con il prelievo `failed`; la - trasmissione va solo al server attivo; un prelievo verso l'indirizzo del - pool o delle commissioni è accettato; l'importo ridotto di un prelievo + trasmissione va solo al server attivo; l'importo ridotto di un prelievo dell'intero saldo non è riconfrontato con il minimo. ### Round @@ -560,7 +559,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa. | `participants_hash` | stringa o `null` | impronta dell'elenco ordinato dei partecipanti, valorizzata dall'ingresso in `drawing` — *da implementare (B-73)* | | `draw_height` | intero o `null` | altezza del blocco di estrazione, fissata all'ingresso in `drawing` — *da implementare (B-73)* | | `draw_confirmations` | intero o `null` | conferme richieste sul blocco di estrazione, fissate all'apertura del round — *da implementare (B-73, B-95)* | - | `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore — *da implementare (B-80)* | + | `pool_address` | stringa o `null` | indirizzo del montepremi del round, verificabile su un esploratore | | `chain_tip_height` | intero o `null` | ultima altezza nota della catena; `null` finché non è nota | | `lottery_paused` | booleano | lotteria in pausa, per qualunque motivo | | `paused_reason` | stringa o `null` | `operator`, `reorg`, `payout_blocked`, `resurrected_tx`, oppure `null` se non in pausa ([modello-dati.md](modello-dati.md), DAT-69) — *da implementare (B-78, B-82, B-85)* | @@ -585,7 +584,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-73, B-78, B-80, B-82, B-85, B-95), per i campi + *Stato:* da implementare (B-73, B-78, B-82, B-85, B-95), per i campi marcati · *Oggi:* quei campi non esistono, `bet_amount_sats` è sempre il costo configurato, e `draw_block_hash` è il seme dell'estrazione. @@ -898,8 +897,8 @@ 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-73, B-79, B-80, B-82), per gli ultimi cinque - campi · *Oggi:* la lista si ferma a `payout_txid`. + *Stato:* da implementare (B-73, B-79, B-82), per gli ultimi quattro + campi · *Oggi:* la lista si ferma a `pool_address`. #### GET /admin/audit-log @@ -1065,7 +1064,7 @@ dei parametri operativi in | `withdrawal_to_own_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale all'indirizzo di deposito dell'utente | sì | | `amount_below_minimum` | 400 | `app/withdrawals/service.py:request_withdrawal` | `minimum_sats` | prelievo sotto il costo attuale della scommessa | sì | | `internal_error` | 500 | `app/main.py:log_unhandled_exception` | — | eccezione non gestita | sì | -| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni — *da implementare (B-80)* | no — da aggiungere | +| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì | | `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì | | `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) — *da implementare* | no — da aggiungere | | `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere | diff --git a/docs/architettura.md b/docs/architettura.md index be01fa8..f3e2084 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -191,7 +191,7 @@ sono vuoti e non sono elencati. |---|---| | `app/wallet/plm_network.py` | Parametri della rete PLM per embit (`PLM_MAINNET`), coin type 746, `ACCOUNT_PATH` | | `app/wallet/keystore.py` | Cifratura/decifratura Fernet della master xprv | -| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`) e del pool (`1/0`), export WIF; la chiave d'account decifrata resta in cache nel processo. *Da implementare:* indirizzo del montepremi per round (`2/`), con `1/0` come indirizzo di riserva (B-80) | +| `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`), del montepremi di ogni round (`2/`, B-80) e dell'indirizzo di riserva (`1/0`), `derive_round_pool_key` che sceglie la chiave che controlla l'indirizzo salvato sul round, export WIF; la chiave d'account decifrata resta in cache nel processo | | `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` | | `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) | | `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79) | diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md index c6ec29e..8e57c01 100644 --- a/docs/ciclo-di-vita-del-round.md +++ b/docs/ciclo-di-vita-del-round.md @@ -152,16 +152,14 @@ richiesta di scommessa (vedi round riceve un proprio indirizzo `m/84'/746'/0'/2/`, salvato in `rounds.pool_address` ed esposto da `GET /rounds/current`. Le scommesse del round pagano quell'indirizzo - ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede) (B-80)) e il + ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#cosa-succede)) e il pagamento spende solo monete di quell'indirizzo ([RND-22]). I round aperti prima di questa modifica mantengono l'indirizzo condiviso `m/84'/746'/0'/1/0`, che diventa l'**indirizzo di riserva** ([RND-38]). Il rifiuto dei prelievi verso indirizzi di montepremi, riserva o - commissioni è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo) (B-80). - *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py:derive_pool_key` · - *Test:* — - *Stato:* da implementare (B-80) · *Oggi:* tutti i round usano lo stesso - indirizzo `m/84'/746'/0'/1/0` (`derive_pool_key`). + commissioni è in [scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo). + *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_round_pool_key` · + *Test:* `tests/unit/test_round_pool_address.py` ## 1 · Round aperto @@ -472,10 +470,12 @@ 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` - *Stato:* da implementare (B-80, B-82) · *Oggi:* spende le monete confermate - dell'indirizzo condiviso lette dal solo server attivo, scelte dalla più - grande fino a coprire il montepremi, qualunque sia la loro provenienza. + *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. - **[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 diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index c36b66e..abe89b2 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -439,15 +439,17 @@ implementato il [piano delle correzioni](piano-correzioni.md). *Codice:* `app/bets/service.py:place_bet` · `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/withdrawals/service.py:request_withdrawal` - **[LIM-17]** **Nessun consolidamento automatico del montepremi.** Monete - avanzate e resti si accumulano sull'unico indirizzo del montepremi; il - margine fra 400 partecipanti e 500 ingressi ([ADR-21](adr/ADR-21-tre-limiti-distinti-sul-numero-monete.md)) si consuma nel tempo. + avanzate e resti si accumulano sugli indirizzi del montepremi (fino a B-80 + su uno solo, per tutti i round); il margine fra 400 partecipanti e 500 + ingressi ([ADR-21](adr/ADR-21-tre-limiti-distinti-sul-numero-monete.md)) si consumava nel tempo. - *Impatto:* raggiunto il limite, il pagamento fallisce con `too_many_inputs` e il round resta in `paying_out`, con un `payout_failed` ogni 60s. - - *Stato:* da chiudere con B-80. - - *Per chiuderlo:* con un indirizzo per round ([ADR-03](adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md)) il pagamento spende - solo le monete del proprio round, e il margine non si consuma più. Resta - accettato dopo il piano: le riserve per l'RBF ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md)) e le monete + - *Stato:* la parte del margine che si consumava è chiusa da B-80: con un + indirizzo per round ([ADR-03](adr/ADR-03-ogni-round-ha-proprio-indirizzo-montepremi.md)) il pagamento spende solo le monete del proprio + round, e il margine non si consuma più (resta lo stato descritto sopra per + i round aperti prima di B-80, che condividono l'indirizzo di riserva). + - *Resta accettato:* le riserve per l'RBF ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md)) e le monete estranee inviate agli indirizzi di round chiusi si accumulano senza consolidamento automatico; recuperarle è un'operazione manuale ([operativita.md](operativita.md#intervento-manuale-sui-fondi)). diff --git a/docs/modello-dati.md b/docs/modello-dati.md index 984a031..b0dfe4d 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -102,7 +102,7 @@ erDiagram bigint bet_amount_sats "snapshot (da implementare)" string fee_address "snapshot (da implementare)" int draw_confirmations "snapshot (da implementare)" - string pool_address "da implementare" + string pool_address string server_seed_enc "cifrato fino alla rivelazione (da implementare)" string server_seed "in chiaro solo dopo la rivelazione (da implementare)" string server_seed_hash "da implementare" @@ -362,9 +362,10 @@ del database sono tutti uguali, perché arrivano tutti dallo stesso percorso *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-80, B-82) · *Oggi:* un solo indirizzo del pool - (`m/84'/746'/0'/1/0`) per tutti i round; il pagamento sceglie fra tutti i - suoi UTXO confermati, di qualunque round o provenienza. + *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. - **[DAT-12]** `spent_txid` ha tre significati: - `NULL`: l'output è spendibile e conta nel saldo; @@ -439,7 +440,7 @@ restano nella tabella come storico. | `bet_amount_sats` | BigInteger | no | backfill da `round_config` nella migrazione | Costo della scommessa di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` | | `fee_address` | String(128) | no | backfill da `round_config` nella migrazione | Destinatario della commissione di **questo** round, copiato all'apertura ([DAT-61]) — *da implementare (B-95)* | `open_new_round_if_needed` | | `draw_confirmations` | Integer | no | `3` (`server_default`) | Conferme richieste sul blocco di estrazione di **questo** round, copiate all'apertura ([DAT-61]) — *da implementare (B-73, B-95)* | `open_new_round_if_needed` | -| `pool_address` | String(128) | no | backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) — *da implementare (B-80)* | `open_new_round_if_needed` | +| `pool_address` | String(128) | no | `""` solo fino alla fine della transazione di apertura; backfill con l'indirizzo di riserva per i round esistenti | Indirizzo del montepremi di **questo** round ([DAT-60]) | `open_new_round_if_needed` | | `server_seed_enc` | Text | sì | `NULL` | Il segreto del round cifrato con Fernet, con la stessa chiave della master xprv; scritto all'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` | | `server_seed` | String(64) | sì | `NULL` | Il segreto in chiaro (32 byte in esadecimale), scritto **solo** alla rivelazione ([DAT-64]) — *da implementare (B-73)* | `RoundScheduler._close_and_draw` | | `server_seed_hash` | String(64) | sì | `NULL` | `sha256(server_seed)` in esadecimale, pubblicato dall'apertura ([DAT-64]) — *da implementare (B-73)* | `open_new_round_if_needed` | @@ -561,9 +562,14 @@ restano nella tabella come storico. indirizzi distinti per costruzione (percorso diverso per ogni `id`), i round vecchi condividono l'indirizzo di riserva. *Codice:* `app/rounds/service.py:open_new_round_if_needed` · - `app/wallet/hd.py:derive_pool_key` · *Test:* — - *Stato:* da implementare (B-80) · *Oggi:* la colonna non esiste; tutte le - scommesse di tutti i round pagano `m/84'/746'/0'/1/0`. + `app/wallet/hd.py:derive_pool_key` · + `migrations/versions/477bddae1a84_add_pool_address_to_rounds.py` · + *Test:* `tests/unit/test_round_pool_address.py` + + La colonna ha `""` come valore di inserimento (l'`id` non esiste prima del + `flush`) e `""` come `server_default`, che serve solo ad aggiungerla a una + tabella con righe; il codice lo riscrive prima del commit e ogni consumatore + rifiuta un indirizzo vuoto. - **[DAT-61]** `bet_amount_sats`, `fee_address` e `draw_confirmations` di `rounds` sono una **copia** dei valori di `round_config` presa quando il @@ -923,8 +929,7 @@ Le funzioni citate sono `app/bets/service.py:place_bet`, questi identificativi, che non cambiano, e non dal txid, che cambia a ogni bump (B-02). L'indirizzo di cui controllare la cronologia si ricava da `kind` e `user_id` e, per un `payout`, da `rounds.pool_address` del - `round_id` (atteso, B-80; oggi è l'unico indirizzo del pool per tutti i - pagamenti). + `round_id` (B-80). *Codice:* `app/tx/pending_address.py:own_address_for` · `app/tx/broadcast.py:_signing_context` · `app/bets/confirmation.py:_on_bet_confirmed` · @@ -1114,7 +1119,7 @@ dall'utente. |---|---|---|---|---|---| | `id` | Integer PK | no | autoincrement | — | database | | `user_id` | FK `users.id`, indicizzata | no | — | Chi preleva | `app/withdrawals/service.py:request_withdrawal` | -| `external_address` | String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente e (atteso, B-80) da ogni indirizzo di montepremi, di riserva e delle commissioni | `request_withdrawal` | +| `external_address` | String(128) | no | — | Destinatario: un indirizzo PLM valido, diverso da quello dell'utente e da ogni indirizzo di montepremi, di riserva e delle commissioni | `request_withdrawal` | | `amount_requested_sats` | BigInteger | no | — | Importo chiesto dall'utente | `request_withdrawal` | | `amount_sent_sats` | BigInteger | sì (sempre valorizzata dal codice) | `NULL` | Quanto riceve il destinatario, vedi [DAT-40] | `request_withdrawal` | | `txid` | String(64) | sì | `NULL` | Txid corrente; `NULL` dopo un fallimento | `request_withdrawal`; `_retarget_txid_references`; `_release_failed_withdrawal`; `_abandon` | @@ -1314,9 +1319,9 @@ letti con una scansione completa. `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-79, B-80, B-82) · *Oggi:* tutti i round - condividono un indirizzo, il pagamento sceglie fra tutti i suoi UTXO - confermati e può avere un resto verso quello stesso indirizzo; non c'è + *Stato:* da implementare (B-79, B-82) · *Oggi:* il pagamento sceglie fra + tutti gli UTXO confermati dell'indirizzo del round e può avere un resto + verso quello stesso indirizzo; non c'è riserva, e vale `pool_amount_sats` = `winner_amount_sats` + `fee_amount_sats` + commissione di rete. @@ -1439,7 +1444,7 @@ letti con una scansione completa. schema. Una migrazione che usi `batch_alter_table` su `rounds` o `users` deve quindi, nella stessa `upgrade()`, eliminare e ricreare esplicitamente l'indice relativo con SQL grezzo, dopo il blocco `batch_alter_table`. Le - migrazioni di B-73/B-80/B-81/B-95 aggiungono colonne a `rounds`: se + migrazioni di B-73/B-81/B-95 (e quella di B-80, già scritta) aggiungono colonne a `rounds`: se aggiungono solo colonne con `op.add_column` (che su SQLite non ricrea la tabella) l'indice resta; se usano `batch_alter_table`, devono ricrearlo. Il test di [DAT-75] è ciò che rende questa regola verificabile. @@ -1499,10 +1504,11 @@ In ordine di applicazione (ogni riga rivede la precedente): | `ee8508d98d34` | Tabella `bug_reports`, con un booleano `resolved` | | `be71fdac734e` | `bug_reports.resolved` sostituito da `status` a tre valori (`resolved` → `'resolved'`, il resto → `'open'`) | | `c1d4a97b5e10` | Indice `ix_users_username_lower` (B-57), [DAT-58] | -| `283844a44b4a` | `rounds.duration_seconds`/`cooldown_seconds` (B-61), backfill dalla configurazione attuale — **head** | +| `283844a44b4a` | `rounds.duration_seconds`/`cooldown_seconds` (B-61), backfill dalla configurazione attuale | +| `477bddae1a84` | `rounds.pool_address` (B-80, [DAT-60]), backfill con l'indirizzo di riserva per i round esistenti — **head** | Revisioni attese, non ancora scritte (ciascuna nel commit della sua voce, -con i backfill di [DAT-55]): `rounds` (`pool_address`, `bet_amount_sats`, +con i backfill di [DAT-55]): `rounds` (`bet_amount_sats`, `fee_address`, `draw_confirmations`, `server_seed`, `server_seed_hash`, `participants_hash`, `draw_height`, `bump_reserve_sats`, `payout_blocked_at`), `round_participants` (`bet_vout`, `draw_position` e il vincolo unico), diff --git a/docs/operativita.md b/docs/operativita.md index 950662d..8e1b34c 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -593,10 +593,13 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). pagamento `building` o `pending` non risolto. Controllare in "Transazioni pendenti" se c'è un `payout` di un round precedente ancora aperto: finché resta tale, i suoi input sono esclusi. L'indirizzo del montepremi non è - mostrato dal pannello; si ottiene con - `docker compose exec app python -c "from app.wallet.hd import derive_pool_address; print(derive_pool_address())"` - e se ne può controllare il saldo con un explorer. Dopo B-80 ogni round ha - il proprio indirizzo (`rounds.pool_address`), e dopo B-82 un'uscita di + mostrato dal pannello (solo `GET /rounds/current` e `GET /admin/rounds` ne + danno `pool_address`); ogni round ha il proprio indirizzo + (`rounds.pool_address`, `m/84'/746'/0'/2/`), e quello di riserva + (`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 scommessa scomparsa dalla catena esclude quel partecipante invece di bloccare il pagamento; solo la scommessa scomparsa del vincitore lo blocca ([OPS-30]). @@ -862,10 +865,10 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). Spendere UTXO del montepremi fuori dal sistema non è rilevato come spesa esterna (l'indirizzo non è in `utxo_events`) e può far fallire il pagamento di un round in corso (`insufficient pool UTXOs` o una trasmissione - rifiutata): farlo solo senza round in `paying_out`. Dopo B-80 ogni round ha + rifiutata): farlo solo senza round in `paying_out`. Ogni round ha il proprio indirizzo `m/84'/746'/0'/2/` e `m/84'/746'/0'/1/0` - diventa l'indirizzo di riserva; il pagamento di un round spende solo le - uscite delle sue scommesse. + è l'indirizzo di riserva; il pagamento di un round spende solo le monete del + suo indirizzo (dopo B-82, solo le uscite delle sue scommesse). *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md index 7f2a515..9145767 100644 --- a/docs/registrazione-e-depositi.md +++ b/docs/registrazione-e-depositi.md @@ -178,10 +178,8 @@ riceve la riserva per l'RBF di ogni pagamento registrate tra le monete degli utenti: il pagamento le legge dalla rete e le verifica con il quorum ([ELC-27](connessione-electrum.md#5-il-quorum)). - *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` · - *Test:* `tests/unit/test_pending_address.py` - *Stato:* da implementare (B-80) · *Oggi:* esiste un solo indirizzo del - montepremi, `m/84'/746'/0'/1/0`, per tutti i round. + *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`, `app/wallet/hd.py:derive_round_pool_key` · + *Test:* `tests/unit/test_round_pool_address.py`, `tests/unit/test_pending_address.py` ## Deposito diff --git a/docs/scommesse-e-prelievi.md b/docs/scommesse-e-prelievi.md index ebb381c..ce0825a 100644 --- a/docs/scommesse-e-prelievi.md +++ b/docs/scommesse-e-prelievi.md @@ -119,10 +119,9 @@ ancora `open` oltre la scadenza. *Codice:* `app/wallet/psbt_builder.py:MAX_PARTICIPANTS_PER_ROUND`, `app/bets/service.py:place_bet` · *Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_bets.py` -Oggi il montepremi è un solo indirizzo per tutti i round, e la differenza fra -400 e 500 copre anche i resti accumulati dai pagamenti precedenti; con un -indirizzo per round -([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-80) restano solo i +Con un indirizzo per round +([RND-35](ciclo-di-vita-del-round.md#0--apertura)) la differenza fra 400 e +500 non si consuma più con i resti dei pagamenti precedenti: restano solo i pagamenti arrivati da fuori. Il numero di ingressi non è l'unica cosa che può impedire un pagamento: vedi [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md#4--pagamento). @@ -162,10 +161,7 @@ flowchart TB [RND-35](ciclo-di-vita-del-round.md#0--apertura)), e il resto (monete spese meno il costo) verso l'indirizzo dell'utente. *Codice:* `app/wallet/psbt_builder.py:select_utxos`, `app/wallet/psbt_builder.py:build_signed_transaction`, `app/bets/service.py:place_bet` · - *Test:* `tests/unit/test_psbt_builder.py` - *Stato:* da implementare (B-80) · *Oggi:* tutte le scommesse di tutti i - round pagano lo stesso indirizzo (`derive_pool_address`, - `m/84'/746'/0'/1/0`). + *Test:* `tests/unit/test_psbt_builder.py`, `tests/unit/test_round_pool_address.py` - **[BET-13]** La **commissione di rete** è la dimensione stimata della transazione per `fee_rate_sat_vb` (1 sat/vB predefinito, modificabile da `/admin`) ed è **dedotta dal costo** della scommessa: al montepremi arriva @@ -271,7 +267,7 @@ HTTP 400 prima che si muova qualunque cifra: |---|---|---| | **[WDR-02]** | L'indirizzo di destinazione è un indirizzo PLM bech32: inizia con `plm1` ed è decodificabile. Un indirizzo di un'altra catena (ad esempio `bc1…`) o in formato legacy (`P…`) è rifiutato | `invalid_address` | | **[WDR-03]** | L'indirizzo di destinazione non è l'indirizzo di deposito dell'utente stesso | `withdrawal_to_own_address` | -| **[WDR-14]** | L'indirizzo di destinazione non è un indirizzo della piattaforma: l'indirizzo del montepremi di un round qualunque (`rounds.pool_address`), l'indirizzo di riserva (`m/84'/746'/0'/1/0`, [RND-35](ciclo-di-vita-del-round.md#0--apertura)), il `fee_address` corrente di `round_config` o quello fotografato su un round. *Stato:* da implementare (B-80) · *Oggi:* nessun controllo; un prelievo verso il montepremi finisce fra le monete di un round | `withdrawal_to_platform_address` | +| **[WDR-14]** | L'indirizzo di destinazione non è un indirizzo della piattaforma: l'indirizzo del montepremi di un round qualunque (`rounds.pool_address`), l'indirizzo di riserva (`m/84'/746'/0'/1/0`, [RND-35](ciclo-di-vita-del-round.md#0--apertura)), il `fee_address` corrente di `round_config` o quello fotografato su un round. *Codice:* `app/withdrawals/service.py:request_withdrawal` · *Test:* `tests/unit/test_round_pool_address.py` | `withdrawal_to_platform_address` | | **[WDR-04]** | L'importo è almeno pari al costo di una scommessa **corrente** in `round_config` (`bet_amount_sats`; non esiste un minimo separato, e non si usa il costo fotografato su un round) | `amount_below_minimum` (parametro `minimum_sats`) | | **[WDR-05]** | La somma delle monete confermate e non spese copre l'importo | `balance_pending_confirmation` se lo coprirebbe il saldo comprensivo di quanto è in corso (parametro `pending_sats`), altrimenti `insufficient_balance` (parametro `required_sats`) | | **[WDR-06]** | L'importo si copre con al massimo 50 monete (`MAX_TX_INPUTS`) | `too_many_inputs` (parametro `max_inputs`) | diff --git a/docs/sicurezza.md b/docs/sicurezza.md index bef1da7..ee0dbd5 100644 --- a/docs/sicurezza.md +++ b/docs/sicurezza.md @@ -231,9 +231,8 @@ sì che un backup dei dati non contenga entrambe. ([REG-14](registrazione-e-depositi.md#il-conto-del-montepremi)) — stanno nella stessa master key, non sono segreti separati. Non esiste un endpoint che ne esporti la chiave privata. - *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address` - *Stato:* da implementare (B-80), per le chiavi per round · *Oggi:* esiste - una sola chiave del montepremi, `m/84'/746'/0'/1/0`, per tutti i round. + *Codice:* `app/wallet/hd.py:derive_pool_key`, `app/wallet/hd.py:derive_pool_address`, `app/wallet/hd.py:derive_round_pool_key` · + *Test:* `tests/unit/test_round_pool_address.py` ### Caricamento a runtime @@ -747,13 +746,10 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no. (`bc1…`) sarebbe altrimenti uno script valido su cui i fondi andrebbero persi. Il prelievo verso il proprio indirizzo di deposito è rifiutato, e così quello verso un indirizzo del montepremi, di riserva o delle - commissioni ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo), - B-80). + commissioni ([scommesse-e-prelievi.md](scommesse-e-prelievi.md#prelievo)). *Codice:* `app/wallet/address.py:is_valid_plm_address`, `app/withdrawals/service.py:request_withdrawal` · - *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py` - *Stato:* da implementare (B-80), per gli indirizzi di montepremi, riserva - e commissioni · *Oggi:* è rifiutato solo il proprio indirizzo di deposito. + *Test:* `tests/unit/test_withdrawals.py`, `tests/unit/test_qr.py`, `tests/unit/test_round_pool_address.py` - **[SEC-44]** `GET /qr/{address}` è pubblico (lo carica un ``, che non può portare il token) ma limitato: rifiuta con 400 `invalid_address` un @@ -809,10 +805,10 @@ sicurezza. lotteria in pausa ([OPS-27](operativita.md#ripristino), B-90), e ogni round ha il proprio indirizzo del montepremi, le cui monete sono verificate con il quorum prima del pagamento - ([RND-35](ciclo-di-vita-del-round.md#0--apertura), B-80, B-82): un round + ([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 tutti i round condividono lo stesso indirizzo. + database, e le monete del round non sono ancora verificate con il quorum. - **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 diff --git a/docs/storico-bug.md b/docs/storico-bug.md index 66e1d43..5446698 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -228,7 +228,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | **aperto** | — | TX | | B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | **aperto** | — | TX, ELC | | B-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | **aperto** | — | RND, TX | -| B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | **aperto** | — | RND, BET, WDR | +| 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 | **aperto** | — | 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-83 | Conferme e reintegri si basano solo sul server attivo | Alta | **aperto** | — | ELC, TX, DEP | @@ -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. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80. - **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 (9 critici, + B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (8 critici, 8 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/docs/transazioni-e-riconciliazione.md b/docs/transazioni-e-riconciliazione.md index 7d451a3..472eb1e 100644 --- a/docs/transazioni-e-riconciliazione.md +++ b/docs/transazioni-e-riconciliazione.md @@ -182,10 +182,9 @@ monete: per indirizzo a ogni giro; un indirizzo che non si riesce a leggere non blocca gli altri. *Codice:* `app/tx/confirmation.py:poll_once`, `app/tx/pending_address.py:own_address_for` · - *Test:* `tests/unit/test_confirmation.py` - *Stato:* da implementare (B-77, B-80) · *Oggi:* cerca solo `current_txid`, - nella cronologia dell'unico indirizzo del montepremi condiviso da tutti i - round per i pagamenti; un'altezza maggiore di 0 riportata dal solo server + *Test:* `tests/unit/test_confirmation.py`, `tests/unit/test_round_pool_address.py` + *Stato:* da implementare (B-77) · *Oggi:* cerca solo `current_txid`, + nella cronologia dell'indirizzo del montepremi del round per i pagamenti; un'altezza maggiore di 0 riportata dal solo server attivo basta a confermare ([TX-22]). - **[TX-22]** **Conferma verificata.** Un candidato ([TX-06]) è confermato solo quando il quorum dei server diff --git a/migrations/versions/477bddae1a84_add_pool_address_to_rounds.py b/migrations/versions/477bddae1a84_add_pool_address_to_rounds.py new file mode 100644 index 0000000..ad90540 --- /dev/null +++ b/migrations/versions/477bddae1a84_add_pool_address_to_rounds.py @@ -0,0 +1,55 @@ +"""add pool_address to rounds + +Revision ID: 477bddae1a84 +Revises: 283844a44b4a +Create Date: 2026-10-14 12:28:11.529107 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '477bddae1a84' +down_revision: Union[str, Sequence[str], None] = '283844a44b4a' +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-80 (DAT-60): every round gets its own pool address. Rounds that already exist were + all paid to the one shared pool address, the reserve address m/84'/746'/0'/1/0, so + that is what they are backfilled with and what their payout keeps spending. + + The column is added with add_column, never batch_alter_table: on SQLite the latter + rebuilds the table and silently drops ix_rounds_single_active (B-97, DAT-56). The + server default is only there to let NOT NULL be added to a table that has rows. + """ + op.add_column("rounds", sa.Column("pool_address", sa.String(length=128), nullable=False, server_default="")) + + connection = op.get_bind() + has_rounds = connection.execute(sa.text("SELECT COUNT(*) FROM rounds")).scalar() + if has_rounds: + # Derived only when there is something to backfill, so a fresh database (and the + # migration test) needs no master key. Imported here for the same reason. + from app.wallet.hd import derive_pool_address + + connection.execute( + sa.text("UPDATE rounds SET pool_address = :address WHERE pool_address = ''"), + {"address": derive_pool_address()}, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # drop_column on SQLite is a table rebuild too, which loses the expression index: + # recreate it (B-97). + op.drop_column("rounds", "pool_address") + 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})") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..1dc6d17 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,25 @@ +"""Every test runs against a throwaway master key, never the operator's real one. + +B-80: opening a round now derives its pool address, so code paths that merely open a +round (the scheduler, `POST /bets`, the round route) need a master key — and without +this they silently read whatever `MASTER_KEY_PATH`/`XPRV_ENCRYPTION_KEY` of the local +`.env` point at, i.e. the real encrypted key on a developer machine and nothing at all +on a clean checkout. Tests that build their own key (most of them) simply override it. +""" + +import pytest +from cryptography.fernet import Fernet + +from app.config import settings + + +@pytest.fixture(autouse=True) +def _throwaway_master_key(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "autouse-master.xprv.enc")) + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + from app.wallet import hd + + hd._account_key = None + hd.generate_master_key() + yield + hd._account_key = None diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index e17c4a7..d534469 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -373,3 +373,20 @@ async def test_admin_pending_transactions_status_filter(client): entries = resp.json() assert len(entries) == 1 assert entries[0]["status"] == "confirmed" + + +async def test_admin_list_rounds_shows_each_rounds_pool_address(client): + """API-36 / B-80: pool_address per round; null for a row without one.""" + from app.db import base as db_base + from app.db.models import Round + from app.wallet.hd import derive_pool_address + + async with db_base.AsyncSessionLocal() as session: + session.add(Round(id=1, status="closed", pool_address=derive_pool_address(1))) + session.add(Round(id=2, status="closed")) + await session.commit() + + resp = await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"}) + by_id = {row["id"]: row for row in resp.json()} + assert by_id[1]["pool_address"] == derive_pool_address(1) + assert by_id[2]["pool_address"] is None diff --git a/tests/unit/test_confirmation.py b/tests/unit/test_confirmation.py index 2e9129e..26912ae 100644 --- a/tests/unit/test_confirmation.py +++ b/tests/unit/test_confirmation.py @@ -10,7 +10,7 @@ from app.db.base import Base from app.db.models import PendingTransaction, Round, RoundParticipant, User from app.electrum.scripthash import address_to_scripthash from app.tx.confirmation import poll_once -from app.wallet.hd import derive_user_address +from app.wallet.hd import derive_pool_address, derive_user_address class FakeClient: @@ -119,7 +119,7 @@ async def test_unconfirmed_tx_is_left_pending(session_factory): async def test_payout_confirmation_closes_round(session_factory): async with session_factory() as session: - session.add(Round(id=3, status="paying_out", payout_txid="tx3")) + session.add(Round(id=3, status="paying_out", payout_txid="tx3", pool_address=derive_pool_address(3))) session.add( PendingTransaction( kind="payout", round_id=3, current_txid="tx3", fee_rate_sat_vb=1, raw_tx_hex="00", status="pending" @@ -222,7 +222,7 @@ async def test_bet_confirms_after_an_rbf_bump_changed_the_txid(session_factory): async def test_payout_confirms_after_an_rbf_bump_changed_the_txid(session_factory): async with session_factory() as session: - session.add(Round(id=12, status="paying_out", payout_txid="old-payout")) + session.add(Round(id=12, status="paying_out", payout_txid="old-payout", pool_address=derive_pool_address(12))) session.add( PendingTransaction( kind="payout", round_id=12, current_txid="bumped-payout", fee_rate_sat_vb=2, diff --git a/tests/unit/test_pending_address.py b/tests/unit/test_pending_address.py index a807f7f..6d79bcd 100644 --- a/tests/unit/test_pending_address.py +++ b/tests/unit/test_pending_address.py @@ -32,13 +32,33 @@ async def session_factory(tmp_path, monkeypatch): hd._account_key = None -async def test_payout_uses_the_pool_address_regardless_of_user_id(session_factory): +async def test_payout_uses_the_pool_address_stored_on_its_round(session_factory): + """B-80 (DAT-60): the address comes from the round row, whatever the user_id.""" + from app.db.models import Round from app.wallet.hd import derive_pool_address async with session_factory() as session: - address = await own_address_for(session, "payout", None) + session.add(Round(id=7, status="paying_out", pool_address=derive_pool_address(7))) + session.add(Round(id=8, status="closed", pool_address=derive_pool_address(8))) + await session.flush() + address_7 = await own_address_for(session, "payout", None, 7) + address_8 = await own_address_for(session, "payout", None, 8) - assert address == derive_pool_address() + assert address_7 == derive_pool_address(7) + assert address_8 == derive_pool_address(8) + assert address_7 != address_8 + + +async def test_payout_of_a_round_without_a_pool_address_is_an_error(session_factory): + from app.db.models import Round + + async with session_factory() as session: + session.add(Round(id=9, status="closed")) # pool_address stays "" + await session.flush() + with pytest.raises(ValueError): + await own_address_for(session, "payout", None, 9) + with pytest.raises(ValueError): + await own_address_for(session, "payout", None, None) @pytest.mark.parametrize("kind", ["bet", "withdrawal"]) diff --git a/tests/unit/test_reconcile.py b/tests/unit/test_reconcile.py index 2a47e49..5b04f6b 100644 --- a/tests/unit/test_reconcile.py +++ b/tests/unit/test_reconcile.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.config import settings from app.db.base import Base -from app.db.models import AuditLog, PendingTransaction, RoundParticipant, User, UtxoEvent, Withdrawal +from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant, User, UtxoEvent, Withdrawal from app.tx.reconcile import reconcile_once @@ -309,13 +309,16 @@ async def test_abandoned_withdrawal_is_marked_failed_and_kept(session_factory): async def test_reconcile_once_caches_history_per_scripthash(session_factory): - """Two payout PendingTransaction rows always share the same pool address — + """Two payout PendingTransaction rows of one round share that round's pool address (B-80) — fetching its history twice in one pass would be wasteful and, at scale across many candidates on one address, needlessly slow the whole tick.""" from datetime import datetime, timedelta, timezone + from app.wallet.hd import derive_pool_address + async with session_factory() as session: old = datetime.now(timezone.utc) - timedelta(hours=7) + session.add(Round(id=1, status="paying_out", pool_address=derive_pool_address(1))) session.add( PendingTransaction( kind="payout", round_id=1, current_txid="payout-a", fee_rate_sat_vb=1, @@ -324,7 +327,7 @@ async def test_reconcile_once_caches_history_per_scripthash(session_factory): ) session.add( PendingTransaction( - kind="payout", round_id=2, current_txid="payout-b", fee_rate_sat_vb=1, + kind="payout", round_id=1, current_txid="payout-b", fee_rate_sat_vb=1, raw_tx_hex=_RAW_TX, status="pending", broadcast_at=old, last_broadcast_at=old, ) ) diff --git a/tests/unit/test_round_pool_address.py b/tests/unit/test_round_pool_address.py new file mode 100644 index 0000000..f9dc601 --- /dev/null +++ b/tests/unit/test_round_pool_address.py @@ -0,0 +1,319 @@ +"""B-80: every round has its own pool address (RND-35, DAT-60, DAT-11, REG-14, SEC-08, +BET-12, RND-22, WDR-14, API-23, API-36). + +All bets of all rounds used to pay one address `m/84'/746'/0'/1/0`: its history grew +without bound, other rounds' money and stray coins mixed with a round's own, and the +payout picked from everything it held. Now round N pays `m/84'/746'/0'/2/`, stored +on the round; the old shared address survives as the *reserve* address and as the pool +of the rounds opened before the change. +""" + +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pytest +from embit import script +from embit.ec import PublicKey +from embit.transaction import Transaction +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.bets.service import place_bet +from app.config import settings +from app.db.base import Base +from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, User, UtxoEvent, Withdrawal +from app.electrum.scripthash import address_to_scripthash +from app.rounds.scheduler import RoundScheduler +from app.rounds.service import open_new_round_if_needed +from app.wallet import hd +from app.wallet.hd import derive_pool_address, derive_pool_key, derive_round_pool_key, derive_user_address +from app.wallet.plm_network import PLM_MAINNET +from app.withdrawals.service import WithdrawalError, request_withdrawal +from tests.unit.test_scheduler import ( # noqa: F401 (fixture + helpers shared with the payout tests) + _FEE_ADDRESS, + _POOL_AMOUNT_SATS, + FakePayoutListener, + _seed_paying_out_round, + payout_session_factory, +) + +_ROOT = Path(__file__).resolve().parents[2] +_EXTERNAL = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx" + + +def _address_of(key) -> str: + return script.p2wpkh(key.to_public()).address(network=PLM_MAINNET) + + +# --- derivation --------------------------------------------------------------- + + +def test_each_round_has_its_own_branch_2_address_and_the_reserve_is_branch_1(): + """SEC-08 / REG-14: round N is m/84'/746'/0'/2/N, no round id is the reserve 1/0.""" + account = hd._load_account_key() + assert _address_of(derive_pool_key(5)) == _address_of(account.derive("2/5")) + assert derive_pool_address(5) == _address_of(account.derive("2/5")) + assert derive_pool_address() == _address_of(account.derive("1/0")) # the reserve + assert len({derive_pool_address(1), derive_pool_address(2), derive_pool_address()}) == 3 + with pytest.raises(ValueError): + derive_pool_key(0) + + +def test_the_signing_key_must_control_the_stored_address(): + """DAT-60 / RND-22: a round's own address is signed with its own key, a round opened + before B-80 (reserve address stored) with the reserve key, anything else is refused.""" + assert _address_of(derive_round_pool_key(3, derive_pool_address(3))) == derive_pool_address(3) + legacy = derive_round_pool_key(3, derive_pool_address()) # id 3, but the shared address + assert _address_of(legacy) == derive_pool_address() + with pytest.raises(ValueError): + derive_round_pool_key(3, derive_pool_address(4)) # another round's address + with pytest.raises(ValueError): + derive_round_pool_key(3, "") + + +# --- opening a round ---------------------------------------------------------- + + +@pytest.fixture +async def session_factory(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with async_sessionmaker(engine, expire_on_commit=False)() as session: + session.add(RoundConfig(fee_address=_FEE_ADDRESS, round_cooldown_seconds=0)) + await session.commit() + yield async_sessionmaker(engine, expire_on_commit=False) + await engine.dispose() + + +async def test_opening_a_round_stores_its_own_pool_address(session_factory): + """RND-35 / DAT-60: written in the transaction that inserts the round, from its id.""" + async with session_factory() as session: + first = await open_new_round_if_needed(session) + await session.commit() + first_id, first_address = first.id, first.pool_address + first.status = "closed" + first.closed_at = first.opened_at + await session.commit() + + async with session_factory() as session: + second = await open_new_round_if_needed(session) + await session.commit() + second_id, second_address = second.id, second.pool_address + + assert first_id != second_id + assert first_address == derive_pool_address(first_id) + assert second_address == derive_pool_address(second_id) + assert first_address != second_address + assert "" not in (first_address, second_address) + + +# --- bets --------------------------------------------------------------------- + + +class _RecordingClient: + def __init__(self): + self.broadcasted: list[str] = [] + + async def broadcast(self, raw_tx_hex: str) -> str: + self.broadcasted.append(raw_tx_hex) + return "network-txid" + + +async def _funded_user(session_factory, index: int, sats: int) -> int: + async with session_factory() as session: + user = User( + username=f"user{index}", password_hash="x", derivation_index=index, address=derive_user_address(index) + ) + session.add(user) + await session.commit() + session.add(UtxoEvent(user_id=user.id, txid=f"{index + 1:02x}" * 32, vout=0, amount_sats=sats, confirmed_height=1)) + await session.commit() + return user.id + + +async def test_a_bet_pays_the_pool_address_of_its_round(session_factory): + """BET-12 / B-80: the bet's recipient output is round.pool_address, not the reserve.""" + user_id = await _funded_user(session_factory, 0, 2_000_000_000) + client = _RecordingClient() + async with session_factory() as session: + user = await session.get(User, user_id) + await place_bet(session, client, user) + round_ = (await session.scalars(select(Round))).one() + pool_address = round_.pool_address + + assert pool_address == derive_pool_address(round_.id) + outputs = {o.script_pubkey.address(network=PLM_MAINNET) for o in Transaction.parse(bytes.fromhex(client.broadcasted[0])).vout} + assert pool_address in outputs + assert derive_pool_address() not in outputs # the reserve address gets no bets any more + + +# --- payout ------------------------------------------------------------------- + + +class _RecordingPayoutClient: + def __init__(self, entries): + self._entries = entries + self.scripthashes: list[str] = [] + self.broadcasted: list[str] = [] + + async def listunspent(self, scripthash): + self.scripthashes.append(scripthash) + return self._entries + + async def broadcast(self, raw_tx_hex): + self.broadcasted.append(raw_tx_hex) + return "network-txid" + + +def _signer_address(raw_tx_hex: str) -> str: + witness = Transaction.parse(bytes.fromhex(raw_tx_hex)).vin[0].witness.items + return script.p2wpkh(PublicKey.parse(witness[1])).address(network=PLM_MAINNET) + + +async def _payout(payout_session_factory, pool_address: str, round_id: int = 1): + await _seed_paying_out_round(payout_session_factory, round_id) + async with payout_session_factory() as session: + round_ = await session.get(Round, round_id) + round_.pool_address = pool_address + await session.commit() + 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) + return client + + +async def test_the_payout_reads_and_signs_with_the_round_own_pool_address(payout_session_factory): + """RND-22 / DAT-11 / B-80: only the round's address is asked about, and its key signs.""" + client = await _payout(payout_session_factory, derive_pool_address(1)) + assert client.scripthashes == [address_to_scripthash(derive_pool_address(1))] + assert _signer_address(client.broadcasted[0]) == derive_pool_address(1) + + +async def test_a_round_opened_before_b80_is_still_paid_from_the_reserve_address(payout_session_factory): + """DAT-60 / RND-22: its stored pool_address is the old shared one, signed with 1/0.""" + client = await _payout(payout_session_factory, derive_pool_address(), round_id=6) + assert client.scripthashes == [address_to_scripthash(derive_pool_address())] + assert _signer_address(client.broadcasted[0]) == derive_pool_address() + + +@pytest.mark.parametrize("stored", ["", "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"]) +async def test_a_payout_never_signs_for_an_address_its_keys_do_not_control(payout_session_factory, stored): + """RND-22 / B-80: an empty or foreign stored address builds nothing and sends nothing.""" + client = await _payout(payout_session_factory, stored) + assert client.broadcasted == [] + assert client.scripthashes == [] + async with payout_session_factory() as session: + reasons = (await session.scalars(select(AuditLog.payload_json).where(AuditLog.event_type == "payout_failed"))).all() + assert len(reasons) == 1 and "payout build failed" in reasons[0] + + +async def test_an_rbf_bump_of_a_payout_uses_the_round_own_key(payout_session_factory): + """TX-11 / B-80: tx/broadcast.py:_signing_context follows the round's pool address.""" + from app.tx.broadcast import RbfError, _signing_context + + await _seed_paying_out_round(payout_session_factory, 4) + async with payout_session_factory() as session: + round_ = await session.get(Round, 4) + round_.pool_address = derive_pool_address(4) + await session.commit() + _key, _script, address = await _signing_context(session, "payout", None, 4) + assert address == derive_pool_address(4) + + round_.pool_address = "" + with pytest.raises(RbfError): + await _signing_context(session, "payout", None, 4) + + +# --- withdrawals -------------------------------------------------------------- + + +@pytest.mark.parametrize("which", ["round_pool", "reserve", "fee"]) +async def test_a_withdrawal_to_an_address_of_the_platform_is_refused(session_factory, which): + """WDR-14 / SEC-43 / B-80: refused with withdrawal_to_platform_address before any coin + is selected or marked spent.""" + user_id = await _funded_user(session_factory, 3, 3_000_000_000) + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + await session.commit() + destination = { + "round_pool": round_.pool_address, + "reserve": derive_pool_address(), + "fee": _FEE_ADDRESS, + }[which] + + client = _RecordingClient() + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError) as excinfo: + await request_withdrawal(session, client, user, destination, 1_000_000_000) + assert excinfo.value.code == "withdrawal_to_platform_address" + # Case does not get around it: bech32 is case-insensitive. + with pytest.raises(WithdrawalError) as excinfo: + await request_withdrawal(session, client, user, destination.upper(), 1_000_000_000) + assert excinfo.value.code in ("withdrawal_to_platform_address", "invalid_address") + + assert client.broadcasted == [] + async with session_factory() as session: + assert (await session.scalars(select(UtxoEvent.spent_txid))).all() == [None] + assert (await session.scalars(select(Withdrawal))).all() == [] + + +async def test_the_old_pool_addresses_of_closed_rounds_stay_refused(session_factory): + """WDR-14: a *closed* round's address still belongs to the platform.""" + user_id = await _funded_user(session_factory, 3, 3_000_000_000) + async with session_factory() as session: + session.add(Round(id=40, status="closed", pool_address=derive_pool_address(40))) + await session.commit() + async with session_factory() as session: + user = await session.get(User, user_id) + with pytest.raises(WithdrawalError) as excinfo: + await request_withdrawal(session, _RecordingClient(), user, derive_pool_address(40), 1_000_000_000) + assert excinfo.value.code == "withdrawal_to_platform_address" + + +def test_the_platform_address_error_is_translated_in_every_language(): + """The `error.withdrawal_to_platform_address` code needs all 7 languages (CLAUDE.md).""" + source = (_ROOT / "app" / "static" / "i18n.js").read_text(encoding="utf-8") + assert source.count("'error.withdrawal_to_platform_address'") == 7 + + +# --- migration ---------------------------------------------------------------- + + +def test_the_migration_backfills_existing_rounds_with_the_reserve_address(tmp_path): + """DAT-60 / B-80: rounds that exist at upgrade time all paid the shared address, so they + get the reserve address as pool_address; the expression indexes survive (B-97).""" + key_path = tmp_path / "master.xprv.enc" + db_path = tmp_path / "old.db" + # The autouse fixture already put a throwaway key in `settings`: write it where the + # subprocess will read it and hand it the same encryption key. + key_path.write_bytes(Path(settings.master_key_path).read_bytes()) + env = { + **os.environ, + "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}", + "MASTER_KEY_PATH": str(key_path), + "XPRV_ENCRYPTION_KEY": settings.xprv_encryption_key, + } + + def alembic(*args): + result = subprocess.run( + [sys.executable, "-m", "alembic", *args], cwd=_ROOT, env=env, capture_output=True, text=True, timeout=120 + ) + assert result.returncode == 0, result.stderr[-3000:] + + alembic("upgrade", "283844a44b4a") # the revision just before pool_address + with sqlite3.connect(db_path) as conn: + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')") + conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('open', '2026-01-02 00:00:00')") + alembic("upgrade", "head") + + with sqlite3.connect(db_path) as conn: + addresses = [row[0] for row in conn.execute("SELECT pool_address FROM rounds ORDER BY id")] + indexes = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'index'")} + assert addresses == [derive_pool_address()] * 2 + assert {"ix_rounds_single_active", "ix_users_username_lower"} <= indexes diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py index c47b0f4..fdac195 100644 --- a/tests/unit/test_rounds_route.py +++ b/tests/unit/test_rounds_route.py @@ -277,3 +277,22 @@ async def test_unhandled_errors_use_the_structured_detail_shape(client): assert detail["params"] == {} # The exception text belongs in logs/app.log, never in the response body. assert "secret internal detail" not in resp.text + + +async def test_current_round_exposes_its_own_pool_address(client): + """API-23 / RND-35 / B-80: GET /rounds/current carries the round's pool address, so + anyone can verify its money on an explorer; null when there is no round.""" + from app.db.models import Round + from app.wallet.hd import derive_pool_address + + ac, session_factory = client + + resp = await ac.get("/rounds/current") + assert resp.json()["pool_address"] is None + + async with session_factory() as session: + session.add(Round(id=5, status="open", pool_address=derive_pool_address(5))) + await session.commit() + + resp = await ac.get("/rounds/current") + assert resp.json()["pool_address"] == derive_pool_address(5) diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index c94a170..1aea8ba 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -9,6 +9,7 @@ from app.config import settings from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User from app.rounds.scheduler import RoundScheduler, _reserved_payout_outpoints +from app.wallet.hd import derive_pool_address from app.wallet.psbt_builder import MAX_TX_INPUTS @@ -126,6 +127,7 @@ async def _seed_paying_out_round(session_factory, round_id: int = 1) -> int: status="paying_out", pool_amount_sats=_POOL_AMOUNT_SATS, winner_user_id=winner.id, + pool_address=derive_pool_address(round_id), # B-80 ) ) await session.commit()