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:
1 parent
cc63753439
commit
fd6a91e93d
37 files changed
+673
-144
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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_
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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})")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user