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/<round_id>); 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 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-14 13:52:32 +02:00
1 parent cc63753439
commit fd6a91e93d
37 files changed
+673 -144

No files matched your search

-11
View File
@@ -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/<round_id>`, 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
+4 -4
View File
@@ -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/<round_id>`, reserve `1/0`) + WIF export (`hd.py`), PLM network constants, address/scripthash, balance math, `psbt_builder.py` (build/sign bet, withdrawal, payout; `select_utxos`) |
| `app/electrum/` | `client.py` (JSON-RPC, endpoint parsing, timeouts), `listener.py` (the one connection: rotation, keepalive, header validation, corroboration, deposit crediting) |
| `app/deposits/` | crediting / external-spend detection / reinstatement (`service.py`), periodic sweep (`reconcile.py`) |
| `app/bets/`, `app/withdrawals/` | build+broadcast services and their confirmation handlers |
@@ -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/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **fee comes out of the winner's 70%**, leaving the 30% fee share intact. Same timeout → RBF → rebroadcast pattern.
- *UI, two independent layers.* A generic phase box ("Pagamento al vincitore in corso…") shows to **every** viewer for the whole closing/drawing/paying_out span — pure cosmetic text driven by `status`. **Additively**, a personalized "Hai vinto!/Non hai vinto" box appears only where `user_played` is true (computed via `get_optional_user`, since the endpoint is reachable logged-out) — nobody else has anything to reveal.
- *Reveal timing.* Delayed by at least `draw_animation_seconds`, anchored to the server's `closes_at` so a reload can't reset the countdown, and decoupled from the real (~block-time) wait for `winner_user_id`. Once revealed it's persisted in `localStorage.plm_persisted_result`, surviving the move to `closed` — at which point `get_active_round` stops returning the round and `winner_user_id` disappears from `GET /rounds/current`. `GET /users/me/last-round-result` is the durable DB-backed backstop for a device that missed the live window entirely. Full logic: `refreshRound`/`checkLastRoundResult` in `app/static/app.js`.
+1 -1
View File
@@ -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
+2
View File
@@ -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
]
+4
View File
@@ -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,
+7 -2
View File
@@ -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,
+6
View File
@@ -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/<id>. The id
# only exists once the row is flushed, so the row is inserted with "" and the real
# address is written in the same transaction (rounds/service.py:open_new_round_if_needed)
# — "" is never visible outside it, and every consumer refuses it. Rounds opened before
# B-80 hold the reserve address (m/84'/746'/0'/1/0), their old shared pool.
pool_address: Mapped[str] = mapped_column(String(128), default="", server_default="")
# Set once, when status flips to "drawing" (rounds/scheduler.py:_close_and_draw).
# Lets both the audit log (B-36's draw_stalled entries) and GET /rounds/current
# (draw_waiting_since) measure how long a round has been waiting on a block,
+6 -2
View File
@@ -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))
+4
View File
@@ -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_
+7
View File
@@ -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} 秒后重试。',
+11 -5
View File
@@ -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))
+6 -5
View File
@@ -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
+11 -5
View File
@@ -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)
+3 -3
View File
@@ -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)
+31 -8
View File
@@ -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/<round_id>`, B-80, RND-35): its bets are paid to it and its
payout is signed with it, so one round's money and history never mix with another's.
Without one, the **reserve** key at branch 1, index 0 — the single pool address every
round used before B-80, still the pool of the rounds opened back then and the
destination of the payout's bump reserve. Branch 0 is users. Not a spec requirement
of the coin, an implementation choice to keep every key under the one encrypted
master key rather than in separate secrets."""
if round_id is None:
return _load_account_key().derive("1/0")
if round_id < 1:
raise ValueError(f"invalid round id {round_id}")
return _load_account_key().derive(f"2/{round_id}")
def derive_pool_address() -> str:
pub = derive_pool_key().to_public()
def derive_pool_address(round_id: int | None = None) -> str:
"""The pool address of a round (B-80), or the reserve address without a `round_id`."""
pub = derive_pool_key(round_id).to_public()
return script.p2wpkh(pub).address(network=PLM_MAINNET)
def derive_round_pool_key(round_id: int, pool_address: str) -> HDKey:
"""The key that controls `pool_address`, the address stored on round `round_id`.
A round opened before B-80 stores the reserve address and is signed with the reserve
key; every other round with its own. Checking the derived address against the stored
one means a key can never be used for money it does not control, and a mismatch (a
different master key, a corrupted row) fails loudly instead of building a transaction
that spends someone else's coins."""
for key in (derive_pool_key(round_id), derive_pool_key()):
if script.p2wpkh(key.to_public()).address(network=PLM_MAINNET) == pool_address:
return key
raise ValueError(f"round {round_id}: no pool key controls the stored address {pool_address!r}")
+18 -2
View File
@@ -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",
@@ -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`
+8 -9
View File
@@ -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 |
+1 -1
View File
@@ -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/<round_id>`), 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/<round_id>`, 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) |
+10 -10
View File
@@ -152,16 +152,14 @@ richiesta di scommessa (vedi
round riceve un proprio indirizzo `m/84'/746'/0'/2/<round_id>`, 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
+8 -6
View File
@@ -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)).
+23 -17
View File
@@ -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),
+10 -7
View File
@@ -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/<round_id>`), 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/<round_id>` 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`
+2 -4
View File
@@ -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
+5 -9
View File
@@ -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`) |
+6 -10
View File
@@ -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 `<img>`, 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
+3 -3
View File
@@ -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`.
+3 -4
View File
@@ -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
@@ -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})")
+25
View File
@@ -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
+17
View File
@@ -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
+3 -3
View File
@@ -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,
+23 -3
View File
@@ -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"])
+6 -3
View File
@@ -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,
)
)
+319
View File
@@ -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/<N>`, 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
+19
View File
@@ -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)
+2
View File
@@ -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()