diff --git a/BUGS.md b/BUGS.md index af0d221..a0409cc 100644 --- a/BUGS.md +++ b/BUGS.md @@ -102,21 +102,6 @@ decisions, see `docs/limiti-noti.md`): ## Critical -### B-79 — A payout normally has no change output, so it cannot be fee-bumped - -The pool normally holds exactly the round's bet outputs, the payout spends them all -for exactly `pool_amount_sats`, so there is no change and `bump_fee` can never help. -A payout stuck in a mempool is also never abandoned (the server still knows it): the -round stays in `paying_out` and no new round opens. - -Fix: every payout carries a **bump reserve** output of -`max(DUST_LIMIT_SATS, estimated_vsize × 10 sat/vB)`, taken from the commission share -and paid to the pool reserve address (`m/84'/746'/0'/1/0`, the old pool address); -RBF shrinks that output. `rounds_can_open` refuses a configuration in which a -round of 1 or of `MAX_PARTICIPANTS_PER_ROUND` participants could not cover winner -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-82 — A deposit reorged out after a bet leaves the round unpayable Deposits are credited at 1 confirmation. A user can bet, then (if they lost) diff --git a/CLAUDE.md b/CLAUDE.md index c0aabca..85104a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin ## Project status -All 10 stages of the original build order are code-complete and unit-tested — 620 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 — 637 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below. 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 620 tests +python -m pytest # all 637 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 ``` @@ -99,7 +99,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW | Parameter | Value | Where | |---|---|---| | Bet cost | 10 PLM (`bet_amount_sats = 1_000_000_000`) | `RoundConfig`, admin-editable | -| Prize split | **70% winner / 30% fees**, rounding remainder to fees | **hardcoded** in `rounds/scheduler.py` — a code change, not an admin edit | +| Prize split | **70% winner / 30% platform**, rounding remainder to the platform; the platform's share pays the **bump reserve** (`max(294, vsize(inputs, 3 outputs) × 10)` sat, B-79) to the reserve address `1/0` and the rest goes to `fee_address` | **hardcoded** in `rounds/service.py:winner_share` / `wallet/psbt_builder.py:payout_bump_reserve` — a code change, not an admin edit | | Round duration / cooldown | 600s / 30s | `RoundConfig`, but **snapshotted onto `Round.duration_seconds`/`cooldown_seconds` when a round opens** (B-61) — an edit applies from the next round, never to the one in progress | | Draw animation | 20s (cosmetic frontend minimum only) | `RoundConfig` | | Draw confirmations | 3 blocks on the draw block (`draw_confirmations`, 1–20) | `RoundConfig`, admin-editable, **snapshotted onto `Round.draw_confirmations` at open** (B-73) | @@ -118,7 +118,7 @@ Source of truth: the `PalladiumWallet` repo — [ChainProfiles.cs](../PalladiumW **Maintenance pause** (`RoundConfig.paused`): toggled by `POST /admin/pause` / `POST /admin/resume` — a deliberate operator action with its own "Manutenzione" card in `/admin`, audit-logged `lottery_paused`/`lottery_resumed`, not a plain config field. It only stops the *next* round from opening (`rounds/service.py:open_new_round_if_needed`); a round in progress still closes, draws and pays its winner. Exposed as `lottery_paused` so `/` can show a banner, with `paused_reason` (B-78, DAT-69): `operator`, or an automatic reason the system wrote itself (`resurrected_tx` so far, `rounds/pause.py`) — an operator pause never overwrites an automatic one, and resume clears whichever it is. -**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. +**No `fee_address`, no rounds** (`rounds/service.py:rounds_can_open`, B-66): the payout pays the 30% commission to `fee_address`, which has no column default because an operator must set their own — so until they do, `open_new_round_if_needed` refuses to open a round at all. Otherwise every round took bets, confirmed them and only then discovered it was unpayable, wedging in `paying_out` with money already in the pool and needing manual recovery. Same scope as pausing: a round already in progress still closes, draws and pays out (clearing the address mid-round is exactly the operator slip that must not strand a live round). Surfaced as `lottery_configured` on `GET /rounds/current` — `/` shows a *different* banner from the maintenance one, since "come back later" would be false — and as a warning on `/admin`'s Parametri card, the one screen that can fix it. Anything else that would make a round unpayable belongs in `rounds_can_open` next to it, not discovered at payout time. Since B-79 (RND-40) that includes the amounts: `payout_config_problem(bet_amount_sats, fee_rate_sat_vb)` checks a round of 1 and of `MAX_PARTICIPANTS_PER_ROUND` participants against the winner's network fee, the bump reserve and dust outputs, and `PUT /admin/config` refuses a bet amount or fee rate that fails it with `422 config_not_payable` (the payout reads `fee_rate_sat_vb` live, so raising it mid-round is exactly what could otherwise strand the round in progress). ## Code map @@ -177,7 +177,7 @@ Diagrams: [come-funziona.md](docs/come-funziona.md) (the 5 phases), [ciclo-di-vi - *Bet cutoff is the round's own deadline* (`round_deadline` = `opened_at + Round.duration_seconds`, the value snapshotted at open time — B-61), **not** the DB status: `place_bet` calls `rounds/service.round_accepts_bets`, which rejects once the deadline passes even while `status` is still `"open"` (the 5s scheduler tick can lag behind it). Once a round leaves `open`, no new bets either, and no new round opens until this one is fully `closed`. The deadline is checked twice — on arrival and again after the transaction is built — and the participant row is then committed behind a **compare-and-set on the round row** (`UPDATE rounds ... WHERE status = 'open'`, B-53): the scheduler flips `open` → `closing` in a transaction of its own and only counts in-flight bets afterwards, so without the CAS a bet could commit in between, be excluded from the draw (only `confirmed` participants are drawn) and still have its sats land in the pool with no refund path. Its mirror image on the scheduler side is `_close_and_draw` re-counting in-flight bets in the same session it snapshots the participants from. - *"Yellow light":* closing **waits for every already-broadcast bet to confirm** before drawing, so a bet in flight at the boundary isn't lost (`building` counts as in-flight; what bounds the wait is the reconciler eventually abandoning a bet that never confirms). - *Algorithm* (deliberately simple, meant to be replaced; **commit-reveal since B-73**, spec RND-14 … RND-20, RND-34 … RND-37): at open the round gets a 32-byte `server_seed` — stored only **Fernet-encrypted** with the master-xprv key (`server_seed_enc`; the plaintext column `server_seed` stays NULL until the reveal) — and publishes `server_seed_hash = sha256(seed)`. Entering `drawing` fixes, in one transaction with the status change, the confirmed participants **ordered by bet txid ascending** (public data, no ties — never `broadcast_at`), each one's `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n` lines; `bet_vout` is the output paying the round's pool address), and `draw_height = tip_at_close + 1`, and writes `draw_committed`. The draw waits until the chain has `draw_confirmations` blocks from `draw_height` (snapshotted at open from `round_config`, default 3), then asks **every** server, the active one included, for the header *at that exact height* (`ElectrumListener.agreed_block_hash`: valid work, at least 2 other answers, strict majority of all answers) — no agreement means the same height is asked again, never another block, so whoever mines or relays blocks cannot pick the one that seeds the draw (`draw_header_corroboration_failed`, once per distinct hash the active server reported). `seed = sha256(server_seed ‖ block_hash ‖ participants_hash)` as a big-endian integer, `index = seed mod n`, winner = participant with that `draw_position`; equal probability for everyone, regardless of amount. The reveal (`server_seed` column + `winner_drawn` payload) happens in the same transaction that stores the winner (or when a round closes with no participants), and `GET /rounds/{id}/proof` (public) returns everything needed to recompute it. A tip of 0 at closing means *unknown*: the round stays `closing` (`draw_baseline_tip_unknown`, once per round, B-63) because `draw_height` must come from a height we actually knew. `draw_stalled` fires when the chain has not advanced for 360s while waiting for confirmations, or the quorum keeps failing to agree. `_complete_draw` reads everything it needs from the database (`_close_and_draw` enters `drawing` and then calls it, and since B-81 `_tick` calls it again for any round found in `drawing` — after an exception or a restart — so it is idempotent: a round no longer in `drawing`, or already holding a winner, is left alone); a round opened before B-73 (no `server_seed_enc`) is given a secret as it enters `drawing`. -- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/`, 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. +- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern. - *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`. @@ -263,7 +263,7 @@ Explicit design choices, not derivable from any single file — respect them: Accepted **by design** — distinct from the audit findings above (all fixed), which are not duplicated here. - **The `drawing` wait is unbounded by design** (the draw's entropy genuinely depends on a future block). It resumes on its own after an exception or a restart (B-81), and is observable rather than silent — past `_DRAW_STALL_THRESHOLD_SECONDS` it logs progress and writes a `draw_stalled` audit entry, and `GET /rounds/current`'s `draw_waiting_since` surfaces it live (B-36). -- **RBF handles one shape only**: a single change output, back to the tx's own sender, big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released. +- **RBF handles one shape only**: a single reducible output — the sender's change for a bet/withdrawal, the payout's bump reserve — big enough to absorb the increase. No extra-input fallback, and none would help the case that used to hurt (an amount equal to the whole input total leaves no other UTXO to add) — which is why `build_signed_transaction` now guarantees a change output of at least `DUST_LIMIT_SATS` instead (B-62): a withdrawal for the full balance moves a dust limit less, a bet from a balance equal to the bet is refused with `balance_leaves_no_change`. What's left is a bump whose *delta* exceeds an otherwise-fine change output, which still raises `RbfError`; that tx is eventually abandoned and its UTXOs released. - **Withdrawal and RBF bump are unit-tested but never live-broadcast** (failure and rollback branches included — still not a real network). - **No user-facing history of rounds or transactions.** `GET /users/me/last-round-result` covers exactly one case (the reveal backstop above) and `GET /bug-reports/mine` one more (the reporter's own reports). Admin has `/admin/rounds`, `/admin/pending-transactions`, `/admin/audit-log`; a user has no equivalent — a failed withdrawal leaves a `failed` row they can never see, which argues for closing this. - **Admin auth is one shared bearer token** (`ADMIN_TOKEN`) with no per-admin identity: `audit_log` records *what* changed (config edits as `config_updated`, with before/after) but never *who* did it. It gates the user list, privkey export, password resets and history, so a leak is high-blast-radius. diff --git a/README.md b/README.md index dc5f68b..fff36ba 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ python -m pytest # all tests python -m pytest tests/unit/test_hd.py # one file ``` -620 unit tests cover HD derivation, PSBT building, the Electrum client, bets, +637 unit tests cover HD derivation, PSBT building, the Electrum client, bets, deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config, the pending-inclusive balance calculation, and the SSE push channel. No automated integration tests against a live Electrum connection — mainnet diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 733d58f..8aafb74 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.api.errors import http_error from app.api.timeutil import isoformat_utc from app.audit.log import write_audit_log from app.auth.security import hash_password_async @@ -14,6 +15,7 @@ from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User from app.db.session import get_session from app.rounds.config import get_round_config from app.rounds.pause import AUTOMATIC_REASONS, OPERATOR_REASON +from app.rounds.service import payout_config_problem from app.wallet.address import is_valid_plm_address from app.wallet.hd import derive_user_wif from app.wallet.psbt_builder import MAX_FEE_RATE_SAT_VB @@ -128,6 +130,21 @@ async def update_config( changes[field] = {"from": previous, "to": value} setattr(config, field, value) + # B-79 (RND-40): a bet amount or fee rate under which a round could not be paid out is + # refused before anything is written — the payout reads fee_rate_sat_vb at build time, so + # a rate raised mid-round could otherwise make the round in progress unpayable. + if "bet_amount_sats" in changes or "fee_rate_sat_vb" in changes: + problem = payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb) + if problem is not None: + await session.rollback() + raise http_error( + 422, + "config_not_payable", + "this bet amount and fee rate would make a round impossible to pay out " + f"({problem}); a larger bet amount or a lower fee rate is needed", + reason=problem, + ) + if changes: await write_audit_log(session, "config_updated", changes) await session.commit() @@ -269,6 +286,7 @@ class AdminRoundResponse(BaseModel): pool_address: str | None # B-80 server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret draw_height: int | None # B-73 + bump_reserve_sats: int | None # B-79 (API-36) @router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)]) @@ -299,6 +317,7 @@ async def list_rounds( pool_address=r.pool_address or None, server_seed_hash=r.server_seed_hash, draw_height=r.draw_height, + bump_reserve_sats=r.bump_reserve_sats, ) for r in rounds ] diff --git a/app/db/models.py b/app/db/models.py index 3c9e1e5..a317d26 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -110,6 +110,9 @@ class Round(Base): pool_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None) winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None) fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None) + # B-79 (DAT-73): the value the payout's bump reserve output was created with (it is paid to + # the reserve address and is what an RBF bump shrinks, so this is never rewritten by a bump). + bump_reserve_sats: Mapped[int | None] = mapped_column(BigInteger, default=None) payout_txid: Mapped[str | None] = mapped_column(String(64), default=None) # B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's # 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index f85507e..453792f 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -25,7 +25,7 @@ from app.rounds.events import broadcaster from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict from app.tx.versions import record_version -from app.wallet.hd import derive_round_pool_key +from app.wallet.hd import derive_pool_address, derive_round_pool_key from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction @@ -543,7 +543,7 @@ class RoundScheduler: winner_share_sats=winner_sats, fee_address=fee_address, commission_sats=commission_share, - change_address=pool_address, + reserve_address=derive_pool_address(), # B-79 (RND-38): the bump reserve's destination fee_rate_sat_vb=fee_rate, ) except InsufficientFundsError as exc: @@ -579,6 +579,7 @@ class RoundScheduler: round_ = await session.get(Round, round_id) round_.winner_amount_sats = built.winner_sats round_.fee_amount_sats = built.commission_sats + round_.bump_reserve_sats = built.reserve_sats # B-79 (DAT-73, DAT-22) round_.payout_txid = built.txid pending = PendingTransaction( kind="payout", diff --git a/app/rounds/service.py b/app/rounds/service.py index 36c603c..16fdd53 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -11,6 +11,13 @@ from app.rounds.events import broadcaster from app.rounds.draw import new_server_seed, server_seed_commitment from app.wallet.hd import derive_pool_address from app.wallet.keystore import encrypt_round_seed +from app.wallet.psbt_builder import ( + DUST_LIMIT_SATS, + MAX_PARTICIPANTS_PER_ROUND, + MAX_TX_INPUTS, + estimate_vsize, + payout_bump_reserve, +) logger = logging.getLogger(__name__) @@ -81,19 +88,44 @@ def round_accepts_bets(round_: Round) -> bool: return datetime.now(timezone.utc) < round_deadline(round_) -def rounds_can_open(config: RoundConfig) -> bool: - """Whether the instance is configured well enough to run a round at all (B-66). +def payout_config_problem(bet_amount_sats: int, fee_rate_sat_vb: int) -> str | None: + """Why a round could not be paid out under this bet amount and fee rate, or None (B-79, + RND-40). Checked for the two extremes — 1 participant (the smallest platform share) and + MAX_PARTICIPANTS_PER_ROUND (the largest bump reserve, which grows with the inputs): + the winner's 70% must cover the network fee of the payout and still be at least dust, and + the platform's 30% must cover the bump reserve and still leave the fee address at least + dust. The smallest bet that can reach the pool is the amount minus the fee of the biggest + transaction a user may build (MAX_TX_INPUTS inputs, 2 outputs), so that is what every + participant is assumed to have contributed.""" + net_bet = bet_amount_sats - estimate_vsize(MAX_TX_INPUTS, 2) * fee_rate_sat_vb + for participants in (1, MAX_PARTICIPANTS_PER_ROUND): + pool = participants * net_bet + share = winner_share(pool) + if share - estimate_vsize(participants, 3) * fee_rate_sat_vb < DUST_LIMIT_SATS: + return "winner_share_below_network_fee" + if pool - share - payout_bump_reserve(participants) < DUST_LIMIT_SATS: + return "commission_below_dust_limit" + return None - Only fee_address today, and only because a round without one is unpayable: the - payout pays the 30% commission to it, so build_payout_transaction cannot even be - built. It has no column default for exactly this reason (rounds/config.py) — an - operator must set their own, and until they do there is nothing to guess. + +def rounds_can_open(config: RoundConfig) -> bool: + """Whether the instance is configured well enough to run a round at all (B-66, B-79). + + Two prerequisites, both because a round without them is unpayable: a fee_address — the + payout pays the platform's share to it, so build_payout_transaction cannot even be built, + and it has no column default (rounds/config.py) because an operator must set their own — + and a bet amount and fee rate under which a round of 1 or of + MAX_PARTICIPANTS_PER_ROUND participants can cover the payout's fee, the bump reserve and + dust outputs (payout_config_problem, RND-40). PUT /admin/config refuses the same + combinations, so this only trips on a configuration that predates the check. Anything else that would make a round unpayable belongs here too, next to it, rather than being discovered at payout time. Deliberately not about *pausing*, which is a decision an operator took (RoundConfig.paused) rather than a prerequisite they haven't met yet.""" - return bool(config.fee_address.strip()) + if not config.fee_address.strip(): + return False + return payout_config_problem(config.bet_amount_sats, config.fee_rate_sat_vb) is None async def open_new_round_if_needed(session: AsyncSession) -> Round | None: diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index 50ef066..e105724 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -23,7 +23,7 @@ from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config from app.tx.outcome import BroadcastOutcome, TransactionNetwork, broadcast_with_verdict from app.tx.versions import record_version -from app.wallet.hd import derive_round_pool_key, derive_user_key +from app.wallet.hd import derive_pool_address, 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 @@ -102,6 +102,19 @@ def _find_change_output(tx: Transaction, change_address: str) -> int | None: return None +def _find_reducible_output(tx: Transaction, kind: str, own_address: str) -> int | None: + """The one output an RBF bump takes the extra fee from (TX-11): the sender's own change + for a bet or a withdrawal; for a payout the **bump reserve** output paid to the reserve + address (B-79, RND-38), falling back to a change output back to the pool for a payout + that was already in flight before the reserve existed. Counterparty outputs — the + winner's, the fee address's, an external recipient's — are never candidates.""" + if kind == "payout": + reserve_index = _find_change_output(tx, derive_pool_address()) + if reserve_index is not None: + return reserve_index + return _find_change_output(tx, own_address) + + async def bump_fee( session_factory: async_sessionmaker, client: ElectrumClient, @@ -179,7 +192,7 @@ async def bump_fee( min_valid_delta = vsize * _INCREMENTAL_RELAY_FEE_RATE_SAT_VB fee_delta = max(target_fee - old_fee, min_valid_delta) - change_index = _find_change_output(old_tx, own_address) + change_index = _find_reducible_output(old_tx, kind, own_address) if change_index is None or old_tx.vout[change_index].value <= fee_delta: raise RbfError(f"pending_transaction {pending_id}: no change output large enough to absorb a fee bump") diff --git a/app/wallet/psbt_builder.py b/app/wallet/psbt_builder.py index 83e0994..efe70cd 100644 --- a/app/wallet/psbt_builder.py +++ b/app/wallet/psbt_builder.py @@ -64,6 +64,22 @@ MAX_PAYOUT_TX_INPUTS = 500 # earlier rounds left in the pool, so the gap is the headroom for those. MAX_PARTICIPANTS_PER_ROUND = 400 +# B-79 (RND-38, DAT-73): every payout carries a bump reserve output, taken from the +# platform's share and paid to the reserve address (m/84'/746'/0'/1/0). It is what an RBF +# bump reduces: the pool normally holds exactly the round's bet outputs and the payout +# spends them all, so without it the payout has no output to shrink, can never be bumped, +# and one stuck in a mempool (which the reconciler never abandons while a server knows it) +# kept the round in `paying_out` and the lottery stopped. 10 sat/vB is the rate the reserve +# is sized to absorb, on top of the rate the payout was built at. +PAYOUT_BUMP_RESERVE_SAT_VB = 10 + + +def payout_bump_reserve(n_inputs: int) -> int: + """The reserve output of a payout spending `n_inputs` coins (RND-38): enough to raise the + fee of its three-output transaction to `PAYOUT_BUMP_RESERVE_SAT_VB`, and never below the + dust limit (a smaller output would not be relayed).""" + return max(DUST_LIMIT_SATS, estimate_vsize(n_inputs, 3) * PAYOUT_BUMP_RESERVE_SAT_VB) + class InsufficientFundsError(Exception): """`code` is the machine-readable identifier the API layer forwards to the @@ -248,8 +264,9 @@ class PayoutTransaction: txid: str fee_sats: int winner_sats: int - commission_sats: int - change_sats: int + commission_sats: int # what the fee address receives: the platform share minus the reserve + reserve_sats: int # B-79: the bump reserve output (plus any excess input value, see below) + change_sats: int # inputs beyond winner share + commission, folded into the reserve output spent_utxos: list[Utxo] @@ -262,43 +279,53 @@ def build_payout_transaction( winner_share_sats: int, fee_address: str, commission_sats: int, - change_address: str, + reserve_address: str, fee_rate_sat_vb: int, ) -> PayoutTransaction: - """Build, sign and finalize the round payout: pool -> winner + fee address, - with change back to the pool itself. Per spec, only the winner's share - absorbs the tx fee — the commission (fee_address) output is untouched. + """Build, sign and finalize the round payout: pool -> winner + fee address + reserve. - As in build_signed_transaction, dust-sized pool change is left to the fee - rather than creating an unrelayable output (B-06). + B-79 (RND-21, RND-23, RND-38): three outputs, always. `commission_sats` is the platform's + whole share (30% plus the rounding remainder); the bump reserve (`payout_bump_reserve`) + is taken out of it and paid to `reserve_address`, the output an RBF bump will shrink + (TX-11), and the fee address receives the rest. Per spec, only the winner's share + absorbs the network fee — never the platform's. + + Inputs beyond winner share + commission exist only for rounds opened before the + per-round pool address (the shared pool held other money): that excess is folded into + the reserve output if it is at least the dust limit, and left to the network fee + otherwise, as in build_signed_transaction (B-06). Selection uses MAX_PAYOUT_TX_INPUTS, not the much stricter user-facing MAX_TX_INPUTS (B-52) — the pool holds one UTXO per bet, so the user-protection cap made every round past ~50 participants impossible to pay.""" target = winner_share_sats + commission_sats selected, total_in = select_utxos(utxos, target, max_inputs=MAX_PAYOUT_TX_INPUTS) - fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + commission + pool change + fee = estimate_vsize(len(selected), 3) * fee_rate_sat_vb # winner + fee address + reserve winner_amount = winner_share_sats - fee if winner_amount < DUST_LIMIT_SATS: raise InsufficientFundsError( "winner share too small to cover the network fee", code="winner_share_below_network_fee" ) - if commission_sats < DUST_LIMIT_SATS: + reserve = payout_bump_reserve(len(selected)) + fee_address_amount = commission_sats - reserve + if fee_address_amount < DUST_LIMIT_SATS: raise InsufficientFundsError( - "commission share too small to be paid out (dust)", code="commission_below_dust_limit" + "commission share too small to cover the bump reserve and still be paid out (dust)", + code="commission_below_dust_limit", ) - change = total_in - target - if change < DUST_LIMIT_SATS: - fee += change - change = 0 + excess = total_in - target + if excess >= DUST_LIMIT_SATS: + reserve += excess + else: + fee += excess + excess = 0 vin = [TransactionInput(bytes.fromhex(u.txid), u.vout, sequence=RBF_SEQUENCE) for u in selected] vout = [ TransactionOutput(winner_amount, script.Script.from_address(winner_address)), - TransactionOutput(commission_sats, script.Script.from_address(fee_address)), + TransactionOutput(fee_address_amount, script.Script.from_address(fee_address)), + TransactionOutput(reserve, script.Script.from_address(reserve_address)), ] - if change > 0: - vout.append(TransactionOutput(change, script.Script.from_address(change_address))) tx = Transaction(vin=vin, vout=vout) psbt = PSBT(tx) @@ -319,7 +346,8 @@ def build_payout_transaction( txid=final_tx.txid().hex(), fee_sats=fee, winner_sats=winner_amount, - commission_sats=commission_sats, - change_sats=change, + commission_sats=fee_address_amount, + reserve_sats=reserve, + change_sats=excess, spent_utxos=selected, ) diff --git a/docs/adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md b/docs/adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md index f655ab8..1515385 100644 --- a/docs/adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md +++ b/docs/adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md @@ -1,6 +1,6 @@ # ADR-17 — La commissione del pagamento esce dalla quota del vincitore; la riserva per l'RBF esce dalla quota della piattaforma -- **Stato:** Accettata — implementazione da completare (B-79) +- **Stato:** Accettata - **Area:** Economia del gioco - **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,6 +30,4 @@ commissione verso `fee_address` è quindi il 30% meno la riserva. [ciclo-di-vita-del-round.md](../ciclo-di-vita-del-round.md#4--pagamento) (B-79). *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · -*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share` -*Stato:* da implementare (B-79) · *Oggi:* nessuna riserva: il 30% va intero a -`fee_address` e, nel caso normale, il pagamento non ha resto. +*Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`, `tests/unit/test_payout_builder.py::test_payout_at_the_participant_cap_pays_the_reserve_out_of_the_platform_share`, `tests/unit/test_broadcast.py::test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else` diff --git a/docs/adr/ADR-29-senza-fee-address-apre-nessun-round.md b/docs/adr/ADR-29-senza-fee-address-apre-nessun-round.md index dc74c53..6fd9be3 100644 --- a/docs/adr/ADR-29-senza-fee-address-apre-nessun-round.md +++ b/docs/adr/ADR-29-senza-fee-address-apre-nessun-round.md @@ -1,6 +1,6 @@ # ADR-29 — Senza `fee_address` non si apre nessun round -- **Stato:** Accettata — implementazione da completare (B-79) +- **Stato:** Accettata - **Area:** Round ed estrazione - **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*. @@ -19,6 +19,4 @@ round impagabile va aggiunta nello stesso punto. l'RBF e una commissione sopra la soglia di polvere ([ADR-17](ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79). *Codice:* `app/rounds/service.py:rounds_can_open` · `app/rounds/config.py:get_round_config` · -*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address` -*Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo -`fee_address`. +*Test:* `tests/unit/test_rounds_service.py::test_withholds_new_round_while_no_fee_address_is_configured`, `tests/unit/test_rounds_service.py::test_a_round_in_progress_survives_the_fee_address_being_cleared`, `tests/unit/test_rounds_route.py::test_lottery_configured_flags_a_missing_fee_address`, `tests/unit/test_rounds_service.py::test_no_round_opens_while_the_config_could_not_pay_one`, `tests/unit/test_rounds_route.py::test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_round` diff --git a/docs/api.md b/docs/api.md index 2b3f59f..5b411ad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -789,7 +789,12 @@ dei parametri operativi in - **Risposta `200`:** la configurazione completa, come [API-30](#get-adminconfig). - **Errori:** `422` per qualunque vincolo violato (nessun campo viene - applicato). + applicato). Una modifica di `bet_amount_sats` o `fee_rate_sat_vb` con cui + un round non si potrebbe pagare ([RND-40](ciclo-di-vita-del-round.md#4--pagamento)) + dà `422` con `detail = {"code": "config_not_payable", "message", "params": + {"reason"}}`, dove `reason` è `winner_share_below_network_fee` o + `commission_below_dust_limit`; il controllo usa i valori risultanti dopo + la modifica. - **Effetti:** scrive `config_updated` con i valori prima/dopo dei soli campi davvero cambiati; nessuna scrittura se nulla cambia. Durata, pausa tra round, costo della scommessa, `fee_address` e `draw_confirmations` si @@ -888,8 +893,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-79, B-82), per gli ultimi due campi · *Oggi:* - la lista si ferma a `draw_height`. + *Stato:* da implementare (B-82), per `payout_blocked_at` · *Oggi:* + la lista si ferma a `bump_reserve_sats`. #### GET /admin/audit-log diff --git a/docs/architettura.md b/docs/architettura.md index e862c88..570b33c 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -194,7 +194,7 @@ sono vuoti e non sono elencati. | `app/wallet/hd.py` | Generazione della master key, derivazione di indirizzi/chiavi utente (`0/i`), del montepremi di ogni round (`2/`, B-80) e dell'indirizzo di riserva (`1/0`), `derive_round_pool_key` che sceglie la chiave che controlla l'indirizzo salvato sul round, export WIF; la chiave d'account decifrata resta in cache nel processo | | `app/wallet/address.py` | `is_valid_plm_address`: checksum bech32 + HRP `plm` | | `app/wallet/balance.py` | `recompute_balance` (saldo confermato), `compute_pending_balance` (saldo mostrato) — vedi [registrazione-e-depositi.md](registrazione-e-depositi.md) | -| `app/wallet/psbt_builder.py` | Selezione UTXO, costruzione e firma di scommessa, prelievo e pagamento; limiti `MAX_TX_INPUTS`, `MAX_PAYOUT_TX_INPUTS`, `MAX_PARTICIPANTS_PER_ROUND`, `DUST_LIMIT_SATS`. *Da implementare:* uscita di riserva per l'RBF sul pagamento (B-79) | +| `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`; l'uscita di riserva per l'RBF sul pagamento (`payout_bump_reserve`, B-79) | ### Rete Electrum diff --git a/docs/ciclo-di-vita-del-round.md b/docs/ciclo-di-vita-del-round.md index 59e18e8..0ca7f0b 100644 --- a/docs/ciclo-di-vita-del-round.md +++ b/docs/ciclo-di-vita-del-round.md @@ -94,8 +94,6 @@ richiesta di scommessa (vedi *Codice:* `app/rounds/service.py:open_new_round_if_needed`, `app/rounds/service.py:rounds_can_open` · *Test:* `tests/unit/test_rounds_service.py` - *Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo - che `fee_address` non sia vuoto. - **[RND-05]** All'apertura il round **fotografa** i parametri correnti che lo governano: durata (`round_duration_seconds`, 600s predefiniti), pausa successiva (`round_cooldown_seconds`, 30s predefiniti), costo della @@ -413,9 +411,10 @@ del montepremi del round. `/admin`. *Codice:* `app/rounds/service.py:winner_share`, `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · *Test:* `tests/unit/test_payout_builder.py` - *Stato:* da implementare (B-79, B-82) · *Oggi:* la ripartizione si calcola - su `pool_amount_sats` fissato all'estrazione e l'indirizzo delle - commissioni riceve l'intera quota del 30%. + *Stato:* da implementare (B-82) · *Oggi:* la ripartizione si calcola + su `pool_amount_sats` fissato all'estrazione, senza la verifica di + [RND-39]; la riserva per l'RBF è già tolta dalla quota delle commissioni + (B-79). - **[RND-22]** Il pagamento spende le uscite delle scommesse dei partecipanti del round **verificate** ([RND-39]), all'indirizzo del montepremi del round ([RND-35]), e nient'altro: altre monete arrivate a quell'indirizzo non @@ -447,11 +446,10 @@ del montepremi del round. si costruisce (`winner_share_below_network_fee`, `commission_below_dust_limit`). *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction` · - *Test:* `tests/unit/test_payout_builder.py` (nessun test copre `commission_below_dust_limit`) - *Stato:* da implementare (B-79, B-95) · *Oggi:* la terza uscita è un resto - verso l'indirizzo del montepremi, presente solo se è di almeno 294 sat e - assente quando le monete spese sono esattamente le scommesse del round; non c'è riserva, la soglia di 294 sat si applica all'intera - quota delle commissioni e `fee_address` è letto dalla configurazione. + *Test:* `tests/unit/test_payout_builder.py::test_payout_deducts_fee_only_from_winner_share`, `tests/unit/test_payout_builder.py::test_payout_folds_excess_input_value_into_the_reserve_output`, `tests/unit/test_payout_builder.py::test_payout_leaves_a_dust_sized_excess_to_the_network_fee`, `tests/unit/test_payout_builder.py::test_payout_with_one_participant_at_the_smallest_viable_pool` + *Stato:* da implementare (B-95) · *Oggi:* `fee_address` è letto dalla + configurazione corrente al momento del pagamento, non fotografato + all'apertura. - **[RND-24]** **Prima si scrive, poi si invia**: prima dell'invio il round registra importo del vincitore (`winner_amount_sats`), commissioni (`fee_amount_sats`), riserva per l'RBF (`bump_reserve_sats`, [RND-38]) e @@ -464,9 +462,7 @@ del montepremi del round. (TX-18, B-76, B-78); solo un abbandono libera il round per un nuovo tentativo. *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · - *Test:* `tests/unit/test_scheduler.py` - *Stato:* da implementare (B-79) · *Oggi:* tutto come descritto, tranne - `bump_reserve_sats`, che non esiste. + *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address` - **[RND-25]** Ogni tentativo che si ferma prima di un invio riuscito scrive un evento `payout_failed` con il motivo: `electrum client not connected`, `no fee_address configured`, `winner user not found`, @@ -496,13 +492,8 @@ del montepremi del round. riducendo l'uscita di riserva ([RND-38]); le uscite del vincitore e delle commissioni non cambiano ([TX-11](transazioni-e-riconciliazione.md#rbf-accelerare-una-transazione-lenta)). - *Codice:* `app/tx/broadcast.py:bump_fee` - *Stato:* da implementare (B-79) · *Oggi:* l'RBF può ridurre solo il resto - verso il montepremi, che non esiste quando le monete spese sono - esattamente le scommesse del round: un pagamento bloccato nel - mempool non si può accelerare, e poiché il server lo conosce non viene - nemmeno abbandonato, quindi il round resta in `paying_out` e nessun nuovo - round si apre. + *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_reducible_output` · + *Test:* `tests/unit/test_broadcast.py::test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else`, `tests/unit/test_broadcast.py::test_a_payout_can_be_bumped_repeatedly_while_the_reserve_lasts` - **[RND-28]** Quando il pagamento è confermato il round passa a `closed` e `closed_at` viene riscritto con l'orario della conferma: da lì parte la pausa prima del round successivo. Il vincitore si vede accreditare la @@ -516,9 +507,8 @@ del montepremi del round. sat/vB è la costante `PAYOUT_BUMP_RESERVE_SAT_VB`. La riserva è tolta dalla quota delle commissioni, mai da quella del vincitore; è ciò che l'RBF riduce ([RND-27]). - *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction`, `app/wallet/hd.py:derive_pool_key` · - *Test:* — - *Stato:* da implementare (B-79) · *Oggi:* non esiste alcuna riserva. + *Codice:* `app/wallet/psbt_builder.py:build_payout_transaction`, `app/wallet/psbt_builder.py:payout_bump_reserve`, `app/wallet/hd.py:derive_pool_key` · + *Test:* `tests/unit/test_payout_builder.py`, `tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address` - **[RND-39]** **Verifica prima del pagamento**: prima di costruire il pagamento, le uscite delle scommesse dei partecipanti vengono verificate all'indirizzo del montepremi del round con il quorum dei server @@ -564,11 +554,8 @@ del montepremi del round. così un aumento della commissione di rete a round in corso non può rendere impagabile il round (il pagamento legge `fee_rate_sat_vb` al momento della costruzione). - *Codice:* `app/rounds/service.py:rounds_can_open`, `app/api/routes/admin.py` · - *Test:* — - *Stato:* da implementare (B-79) · *Oggi:* `rounds_can_open` controlla solo - che `fee_address` non sia vuoto, e `PUT /admin/config` non fa verifiche di - pagabilità. + *Codice:* `app/rounds/service.py:payout_config_problem`, `app/rounds/service.py:rounds_can_open`, `app/api/routes/admin.py:update_config` · + *Test:* `tests/unit/test_rounds_service.py::test_payout_config_problem_at_both_extremes`, `tests/unit/test_rounds_service.py::test_no_round_opens_while_the_config_could_not_pay_one`, `tests/unit/test_admin.py::test_config_refuses_a_change_that_would_make_a_round_unpayable`, `tests/unit/test_admin.py::test_a_fee_rate_raised_mid_round_cannot_make_the_round_unpayable` ## Tempi reali diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md index 4088c04..5c550ff 100644 --- a/docs/limiti-noti.md +++ b/docs/limiti-noti.md @@ -71,7 +71,7 @@ implementato il [piano delle correzioni](piano-correzioni.md). tetto al fee rate (`MAX_FEE_RATE_SAT_VB`); abbandono dopo 6 ore per ciò che sparisce dalla rete; ritentativo automatico del pagamento dopo un abbandono. - - *Stato:* da chiudere con B-79 e B-87. + - *Stato:* da chiudere con B-87 (la riserva del pagamento, B-79, c'è). - *Per chiuderlo:* ogni pagamento porta un'uscita di riserva che l'accelerazione riduce ([ADR-17](adr/ADR-17-commissione-pagamento-esce-dalla-quota-vincitore.md), B-79); un aumento che lascerebbe un resto sotto `DUST_LIMIT_SATS` elimina il resto e ne versa il valore in diff --git a/docs/modello-dati.md b/docs/modello-dati.md index 8dc93ec..2564516 100644 --- a/docs/modello-dati.md +++ b/docs/modello-dati.md @@ -115,7 +115,7 @@ erDiagram bigint pool_amount_sats bigint winner_amount_sats bigint fee_amount_sats - bigint bump_reserve_sats "da implementare" + bigint bump_reserve_sats string payout_txid datetime payout_blocked_at "da implementare" } @@ -446,7 +446,7 @@ restano nella tabella come storico. | `pool_amount_sats` | BigInteger | sì | `NULL` | Montepremi: somma delle scommesse confermate e ancora sulla catena ([DAT-21], [DAT-67]) | `_close_and_draw`, `_complete_draw`; `RoundScheduler._trigger_payout` (atteso, B-82) | | `winner_amount_sats` | BigInteger | sì | `NULL` | Quanto riceve il vincitore (70% meno la commissione di rete) | `RoundScheduler._trigger_payout` | | `fee_amount_sats` | BigInteger | sì | `NULL` | Quanto va a `fee_address`: 30% più il resto dell'arrotondamento, meno `bump_reserve_sats` nello schema atteso | `_trigger_payout` | -| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) — *da implementare (B-79)* | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` | +| `bump_reserve_sats` | BigInteger | sì | `NULL` | Output di riserva per l'RBF del pagamento, verso l'indirizzo di riserva ([DAT-73]) | `_trigger_payout`; `app/tx/broadcast.py:bump_fee` | | `payout_txid` | String(64) | sì | `NULL` | Txid corrente della transazione di pagamento | `_trigger_payout`; `_retarget_txid_references`; `app/tx/reconcile.py:_abandon` | | `payout_blocked_at` | DateTime | sì | `NULL` | Il pagamento è trattenuto perché la scommessa del vincitore non è più sulla catena ([DAT-68]) — *da implementare (B-82)* | `_trigger_payout`; `app/api/routes/admin.py:resume_lottery` | @@ -537,10 +537,10 @@ restano nella tabella come storico. `app/tx/broadcast.py:_retarget_txid_references` · `app/tx/reconcile.py:_abandon` · *Test:* `tests/unit/test_scheduler.py`, `tests/unit/test_broadcast.py`, `tests/unit/test_reconcile.py` - *Stato:* da implementare (B-96, B-79) · *Oggi:* l'abbandono azzera solo - `payout_txid` e lascia `winner_amount_sats`/`fee_amount_sats` del tentativo - abbandonato; non esiste un output di riserva e il bump riduce un resto del - pool che normalmente non c'è. + *Stato:* da implementare (B-96) · *Oggi:* l'abbandono azzera solo + `payout_txid` e lascia `winner_amount_sats`, `fee_amount_sats` e + `bump_reserve_sats` del tentativo abbandonato (le riscrive il tentativo + successivo). - **[DAT-60]** Ogni round ha il proprio indirizzo del montepremi, `pool_address`, derivato all'apertura da `m/84'/746'/0'/2/` e scritto @@ -646,9 +646,8 @@ restano nella tabella come storico. è in [ciclo-di-vita-del-round.md](ciclo-di-vita-del-round.md) (B-79). *Codice:* `app/rounds/scheduler.py:RoundScheduler._trigger_payout` · `app/wallet/psbt_builder.py:build_payout_transaction` · - `app/tx/broadcast.py:bump_fee` · *Test:* — - *Stato:* da implementare (B-79) · *Oggi:* la colonna non esiste; il - pagamento non ha di norma alcun resto e quindi non si può accelerare. + `app/tx/broadcast.py:bump_fee` · + *Test:* `tests/unit/test_scheduler.py::test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address`, `tests/unit/test_broadcast.py::test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else`, `tests/unit/test_migrations.py::test_the_bump_reserve_column_exists_at_head_and_is_nullable` ### `round_participants` @@ -1276,11 +1275,10 @@ 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-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. + *Stato:* da implementare (B-82) · *Oggi:* il pagamento sceglie fra + tutti gli UTXO confermati dell'indirizzo del round, senza la verifica di + [DAT-67]; l'identità con la riserva vale già per i round pagati con tutti + e soli i loro ingressi. - ~~**[DAT-47]**~~ Assorbito da [RND-12](ciclo-di-vita-del-round.md): l'attesa delle scommesse in volo prima dell'estrazione è comportamento del round. Il @@ -1467,7 +1465,7 @@ In ordine di applicazione (ogni riga rivede la precedente): Revisioni attese, non ancora scritte (ciascuna nel commit della sua voce, 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`), +`participants_hash`, `draw_height`, `payout_blocked_at`), `round_participants` (`bet_vout`, `draw_position` e il vincolo unico), `round_config` (`deposit_confirmations`, `draw_confirmations`, `max_round_pool_sats`, `paused_reason`, con `paused_reason = 'operator'` per diff --git a/docs/operativita.md b/docs/operativita.md index ed21c7b..44d9d95 100644 --- a/docs/operativita.md +++ b/docs/operativita.md @@ -616,9 +616,10 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). polvere) compare invece come errore di invio nello stesso messaggio. **Causa.** L'RBF riduce solo il resto che torna al mittente; se il resto non basta a coprire l'aumento, o la commissione è già al massimo (10.000 sat/vB), - non si può fare. **Un pagamento di norma non ha resto** (spende esattamente - gli UTXO del montepremi), quindi oggi un pagamento fermo nel mempool non si - può accelerare in nessun caso. + non si può fare. Un pagamento ha un'uscita di riserva + (B-79) che l'aumento riduce; la riserva è dimensionata per arrivare a circa + 10 sat/vB, oltre la quale (o per un pagamento costruito prima di B-79, che + non ce l'ha) la transazione non si può accelerare. **Cosa fare.** Nessuna procedura supportata. La transazione resta in attesa alla commissione che ha: conferma quando il mempool la accetta, oppure, se sparisce dal mempool, viene abbandonata dopo 6 ore ([OPS-16]). Se blocca un @@ -627,8 +628,7 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09). ([OPS-23]); il sistema accrediterà l'uscita della nuova transazione come un normale deposito. Per un pagamento non c'è un'alternativa supportata: la chiave del montepremi non è esportabile ([OPS-24]). - Dopo B-79 ogni pagamento ha un'uscita di riserva che l'RBF può ridurre; - dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere + Dopo B-87 un aumento che lascerebbe un resto sotto la soglia di polvere toglie il resto invece di fallire, e ogni fallimento scrive `rbf_bump_failed`. *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:RbfBumper._tick` · diff --git a/docs/storico-bug.md b/docs/storico-bug.md index a4e0416..9f0dbfa 100644 --- a/docs/storico-bug.md +++ b/docs/storico-bug.md @@ -227,7 +227,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). | B-76 | Un invio ambiguo (timeout, connessione caduta) viene annullato come se fosse rifiutato | Critica | *da registrare* | `test_broadcast_outcome.py`, `test_electrum_client.py` | TX, BET, WDR | | B-77 | Si ricorda solo l'ultimo txid di una transazione accelerata, e la sostituzione viene inviata prima di essere salvata | Critica | *da registrare* | `test_tx_versions.py`, `test_migrations.py`, `test_broadcast.py` | TX | | B-78 | Una transazione viene abbandonata sulla base del silenzio di un solo server | Critica | *da registrare* | `test_reconcile_proof.py`, `test_reconcile.py`, `test_pause_reason.py`, `test_migrations.py` | 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-79 | Il pagamento di norma non ha resto, quindi non si può accelerare e può bloccare la lotteria | Critica | *da registrare* | `test_payout_builder.py`, `test_broadcast.py`, `test_scheduler.py`, `test_rounds_service.py`, `test_admin.py`, `test_migrations.py` | RND, TX | | B-80 | Un solo indirizzo del montepremi per tutti i round | Critica | *da registrare* | `test_round_pool_address.py`, `test_pending_address.py`, `test_scheduler.py` | RND, BET, WDR | | B-81 | `drawing` resta bloccato dopo qualsiasi eccezione, non solo dopo un riavvio | Critica | *da registrare* | `test_draw_proof.py` | RND | | B-82 | Un deposito annullato da una riorganizzazione dopo la scommessa rende il round impagabile; coinbase accreditate a 1 conferma | Critica | **aperto** | — | DEP, RND | @@ -252,9 +252,9 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)). - **Corretti:** B-01 … B-70, tranne B-16, che è chiuso nella sostanza (il crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera - come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81. + come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79. - **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 (2 critici, + B-73 … B-98 dalla revisione del 2026-09-28 tranne i corretti (1 critico, 4 alti, 6 medi), da implementare nell'ordine di [piano-correzioni.md](piano-correzioni.md). - **Prossimo numero libero:** `B-99`. diff --git a/docs/transazioni-e-riconciliazione.md b/docs/transazioni-e-riconciliazione.md index fe11034..c12545f 100644 --- a/docs/transazioni-e-riconciliazione.md +++ b/docs/transazioni-e-riconciliazione.md @@ -244,13 +244,9 @@ sostituibilità fin dal primo invio. mai abbandonata. *Codice:* `app/tx/broadcast.py:bump_fee`, `app/tx/broadcast.py:_find_change_output` · *Test:* `tests/unit/test_broadcast.py` - *Stato:* da implementare (B-79, B-87) · *Oggi:* per il pagamento si cerca - un resto verso l'indirizzo del montepremi, che esiste solo se le monete - scelte superano vincitore più commissioni di almeno 294 sat: quando il - montepremi contiene esattamente le scommesse del round il pagamento le - spende tutte per esattamente `pool_amount_sats`, non ha resto e non è - accelerabile; per scommesse e prelievi basta che il resto superi - l'aumento, così un resto di 294 sat diventa 153 sat, sotto la soglia di + *Stato:* da implementare (B-87) · *Oggi:* il pagamento si accelera già + dall'uscita di riserva (B-79), ma per ogni transazione basta che l'uscita + superi l'aumento: un resto di 294 sat diventa 153 sat, sotto la soglia di dust, e la rete rifiuta la sostituzione a ogni giro. - **[TX-12]** **Prima si scrive la sostituzione, poi si invia.** La sostituzione firmata viene registrata come candidata in diff --git a/migrations/versions/e5a91c3d7b26_payout_bump_reserve.py b/migrations/versions/e5a91c3d7b26_payout_bump_reserve.py new file mode 100644 index 0000000..cc4c6d2 --- /dev/null +++ b/migrations/versions/e5a91c3d7b26_payout_bump_reserve.py @@ -0,0 +1,40 @@ +"""payout bump reserve: rounds.bump_reserve_sats (B-79) + +Revision ID: e5a91c3d7b26 +Revises: d3e8b15a7c42 +Create Date: 2026-10-15 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e5a91c3d7b26' +down_revision: Union[str, Sequence[str], None] = 'd3e8b15a7c42' +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-79 (DAT-73): the value of the bump reserve output a payout was created with. NULL for + every payout built before the reserve existed. + + A plain add_column — never batch_alter_table, which on SQLite rebuilds the table and + silently drops the expression indexes (B-97, DAT-56). + """ + op.add_column("rounds", sa.Column("bump_reserve_sats", sa.BigInteger(), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("rounds", "bump_reserve_sats") + # drop_column on SQLite is a table rebuild: recreate the expression index (B-97). + op.execute("DROP INDEX IF EXISTS ix_rounds_single_active") + op.execute(f"CREATE UNIQUE INDEX ix_rounds_single_active ON rounds ((1)) WHERE status IN ({_ACTIVE})") diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index 2df26da..e539eb6 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -416,3 +416,53 @@ async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client rounds = (await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})).json() assert rounds[0]["server_seed_hash"] == "ab" * 32 and rounds[0]["draw_height"] == 77 assert "server_seed" not in rounds[0] and "cd" * 32 not in str(rounds[0]) + + +# --- B-79 (RND-40, API-31): a bet amount or fee rate that would make a round unpayable --- + + +@pytest.mark.parametrize( + "payload", + [ + {"bet_amount_sats": 5_000}, # the platform's 30% cannot cover the reserve and dust + {"bet_amount_sats": 3_000}, # does not even pay for its own transaction + ], +) +async def test_config_refuses_a_change_that_would_make_a_round_unpayable(client, payload): + headers = {"X-Admin-Token": "test-admin-token"} + before = (await client.get("/admin/config", headers=headers)).json() + resp = await client.put("/admin/config", headers=headers, json=payload) + assert resp.status_code == 422 + detail = resp.json()["detail"] + assert detail["code"] == "config_not_payable" and detail["params"]["reason"] + assert (await client.get("/admin/config", headers=headers)).json() == before # nothing written + audit = (await client.get("/admin/audit-log", headers=headers)).json() + assert not [e for e in audit if e["event_type"] == "config_updated"] + + +async def test_a_fee_rate_raised_mid_round_cannot_make_the_round_unpayable(client): + """The payout reads fee_rate_sat_vb when it is built, so the rate is the one live value + that can still break a round in progress — it is bounded by the same check, judged + together with the stored bet amount (and with a bet amount sent in the same request).""" + headers = {"X-Admin-Token": "test-admin-token"} + put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731 + assert (await put({"bet_amount_sats": 50_000})).status_code == 200 + assert (await put({"fee_rate_sat_vb": 10})).status_code == 200 # still payable: 1 bet nets 15_530 + resp = await put({"fee_rate_sat_vb": 14}) # the 70% no longer covers the payout's network fee + assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable" + assert (await client.get("/admin/config", headers=headers)).json()["fee_rate_sat_vb"] == 10 + # merged with the same request: a larger bet makes the higher rate payable + resp = await put({"bet_amount_sats": 1_000_000, "fee_rate_sat_vb": 20}) + assert resp.status_code == 200 + # ... and the other way round, a bet that is fine alone is refused at the stored rate + assert (await put({"bet_amount_sats": 50_000})).status_code == 422 + + +async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client): + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.put( + "/admin/config", headers=headers, json={"bet_amount_sats": 100_000, "fee_rate_sat_vb": 2, "rbf_timeout_seconds": 120} + ) + assert resp.status_code == 200 + # an unrelated edit is never blocked by the payability check + assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200 diff --git a/tests/unit/test_broadcast.py b/tests/unit/test_broadcast.py index 16b1aa7..02e5752 100644 --- a/tests/unit/test_broadcast.py +++ b/tests/unit/test_broadcast.py @@ -568,3 +568,134 @@ async def test_bump_fee_is_a_noop_when_the_row_is_gone(session_factory): result = await bump_fee(session_factory, client, 999_999) assert result is None assert not client.broadcasted + + +async def _seed_payout(session_factory, *, n: int = 3, bet: int = 1_000_000_000, fee_rate: int = 1): + """A real payout (the production builder, the round's own key, the reserve address) in + `pending`, plus the prevout amounts a fake chain answers with.""" + from app.db.models import Round + from app.rounds.service import winner_share + from app.wallet.hd import derive_pool_address, derive_round_pool_key + from app.wallet.psbt_builder import build_payout_transaction + + pool_address = derive_pool_address(1) + pool_key = derive_round_pool_key(1, pool_address) + winner_address = script.p2wpkh(_key(81).to_public()).address(network=PLM_MAINNET) + fee_address = script.p2wpkh(_key(82).to_public()).address(network=PLM_MAINNET) + utxos = [Utxo(f"{i + 1:064x}", 0, bet) for i in range(n)] + pool = bet * n + built = build_payout_transaction( + signing_key=pool_key, + from_script=script.p2wpkh(pool_key.to_public()), + utxos=utxos, + winner_address=winner_address, + winner_share_sats=winner_share(pool), + fee_address=fee_address, + commission_sats=pool - winner_share(pool), + reserve_address=derive_pool_address(), + fee_rate_sat_vb=fee_rate, + ) + async with session_factory() as session: + session.add(Round(id=1, status="paying_out", pool_address=pool_address, payout_txid=built.txid)) + pending = PendingTransaction( + kind="payout", + round_id=1, + current_txid=built.txid, + fee_rate_sat_vb=fee_rate, + raw_tx_hex=built.raw_hex, + status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + session.add(pending) + await session.commit() + pending_id = pending.id + return built, pending_id, {u.txid: u.amount_sats for u in utxos} + + +async def test_a_payout_is_bumped_out_of_its_reserve_output_and_nothing_else(session_factory): + """B-79 (RND-27, RND-38, TX-11): the payout used to have no output to shrink — it spent + exactly the bet outputs for exactly the pool — so it could never be accelerated. Now the + replacement spends the same coins, leaves the winner's and the fee address's outputs + untouched, and takes the whole increase from the reserve output.""" + from app.wallet.hd import derive_pool_address + + built, pending_id, prevouts = await _seed_payout(session_factory, n=3) + client = FakeClient(prevouts) + + new_txid = await bump_fee(session_factory, client, pending_id) + + assert new_txid is not None and new_txid != built.txid + old_tx = Transaction.parse(bytes.fromhex(built.raw_hex)) + new_tx = Transaction.parse(bytes.fromhex(client.broadcasted[0])) + assert [(v.txid, v.vout) for v in new_tx.vin] == [(v.txid, v.vout) for v in old_tx.vin] + assert len(new_tx.vout) == len(old_tx.vout) == 3 + # outputs 0 (winner) and 1 (fee address) are byte-for-byte the same ... + assert new_tx.vout[0].serialize() == old_tx.vout[0].serialize() + assert new_tx.vout[1].serialize() == old_tx.vout[1].serialize() + # ... and the reserve output paid for the whole increase + assert new_tx.vout[2].script_pubkey.address(network=PLM_MAINNET) == derive_pool_address() + increase = (sum(o.value for o in old_tx.vout) - sum(o.value for o in new_tx.vout)) + assert increase > 0 and new_tx.vout[2].value == old_tx.vout[2].value - increase + assert new_tx.vout[2].value >= 294 + + from app.db.models import Round + + async with session_factory() as session: + assert (await session.get(Round, 1)).payout_txid == new_txid + + +async def test_a_payout_can_be_bumped_repeatedly_while_the_reserve_lasts(session_factory): + """The reserve is sized for 10 sat/vB, so the first several bumps (1 sat/vB each) all + fit — the failure it fixes was the very first one.""" + built, pending_id, prevouts = await _seed_payout(session_factory, n=2) + client = FakeClient(prevouts) + for _ in range(5): + # past the bump timeout every round: pretend the last broadcast was long ago + async with session_factory() as session: + row = await session.get(PendingTransaction, pending_id) + row.last_broadcast_at = datetime.now(timezone.utc) - timedelta(seconds=5000) + await session.commit() + assert await bump_fee(session_factory, client, pending_id) is not None + assert len(client.broadcasted) == 5 + last = Transaction.parse(bytes.fromhex(client.broadcasted[-1])) + assert last.vout[2].value >= 294 + + +async def test_a_payout_without_a_reserve_output_still_cannot_be_bumped(session_factory): + """What the old shape looked like: nothing to shrink, so RbfError — the reason the reserve + exists. (A payout already in flight when this shipped keeps that fate.)""" + from app.db.models import Round + from app.wallet.hd import derive_pool_address, derive_round_pool_key + + pool_address = derive_pool_address(1) + pool_key = derive_round_pool_key(1, pool_address) + winner = script.p2wpkh(_key(83).to_public()).address(network=PLM_MAINNET) + fee = script.p2wpkh(_key(84).to_public()).address(network=PLM_MAINNET) + tx_in = TransactionInput(bytes.fromhex("aa" * 32), 0, sequence=0xFFFFFFFD) + from embit.psbt import PSBT + from embit.finalizer import finalize_psbt + + tx = Transaction( + vin=[tx_in], + vout=[ + TransactionOutput(699_000_000, script.Script.from_address(winner)), + TransactionOutput(299_999_000, script.Script.from_address(fee)), + ], + ) + psbt = PSBT(tx) + psbt.inputs[0].witness_utxo = TransactionOutput(1_000_000_000, script.p2wpkh(pool_key.to_public())) + psbt.sign_with(pool_key) + final = finalize_psbt(psbt) + async with session_factory() as session: + session.add(Round(id=1, status="paying_out", pool_address=pool_address)) + session.add( + PendingTransaction( + kind="payout", round_id=1, current_txid=final.txid().hex(), fee_rate_sat_vb=1, + raw_tx_hex=final.serialize().hex(), status="pending", + broadcast_at=datetime.now(timezone.utc) - timedelta(seconds=1000), + ) + ) + await session.commit() + pending_id = (await session.scalars(__import__("sqlalchemy").select(PendingTransaction))).one().id + with pytest.raises(RbfError): + await bump_fee(session_factory, FakeClient({"aa" * 32: 1_000_000_000}), pending_id) diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index 88c2dad..d6b656a 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -196,3 +196,10 @@ def test_two_participants_cannot_share_a_draw_position_but_null_positions_coexis insert(3, 0) with pytest.raises(sqlite3.IntegrityError): insert(4, 0) + + +def test_the_bump_reserve_column_exists_at_head_and_is_nullable(migrated_db): # B-79 (DAT-73) + with sqlite3.connect(migrated_db) as conn: + rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")} + assert "bump_reserve_sats" in rounds and rounds["bump_reserve_sats"][3] == 0 + assert _index_sql(migrated_db, "ix_rounds_single_active") is not None diff --git a/tests/unit/test_payout_builder.py b/tests/unit/test_payout_builder.py index 5f94712..2e442c5 100644 --- a/tests/unit/test_payout_builder.py +++ b/tests/unit/test_payout_builder.py @@ -5,6 +5,7 @@ from embit.transaction import Transaction from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import ( + DUST_LIMIT_SATS, MAX_PARTICIPANTS_PER_ROUND, MAX_PAYOUT_TX_INPUTS, MAX_TX_INPUTS, @@ -12,6 +13,7 @@ from app.wallet.psbt_builder import ( Utxo, build_payout_transaction, estimate_vsize, + payout_bump_reserve, ) @@ -40,23 +42,35 @@ def test_payout_deducts_fee_only_from_winner_share(): winner_share_sats=winner_share, fee_address=fee_address, commission_sats=commission, - change_address=pool_address, + reserve_address=pool_address, fee_rate_sat_vb=1, ) fee = estimate_vsize(1, 3) + reserve = payout_bump_reserve(1) # B-79 (RND-38): 172 vB * 10 sat/vB + assert reserve == 1_720 assert built.fee_sats == fee assert built.winner_sats == winner_share - fee - assert built.commission_sats == commission # untouched by the fee - assert built.change_sats == pool_amount - (winner_share + commission) + assert built.reserve_sats == reserve + assert built.commission_sats == commission - reserve # the reserve comes out of the platform's share + assert built.change_sats == 0 parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) - assert len(parsed.vout) == 2 # no change needed: winner_share + commission == pool_amount exactly - amounts = sorted(o.value for o in parsed.vout) - assert amounts == sorted([built.winner_sats, built.commission_sats]) + assert len(parsed.vout) == 3 # winner, fee address, reserve — always, B-79 + assert [o.value for o in parsed.vout] == [built.winner_sats, built.commission_sats, built.reserve_sats] + assert [o.script_pubkey.address(network=PLM_MAINNET) for o in parsed.vout] == [ + winner_address, + fee_address, + pool_address, # stands in for the reserve address here + ] + # nothing is created or lost: the inputs are exactly the outputs plus the network fee + assert sum(o.value for o in parsed.vout) + built.fee_sats == pool_amount -def test_payout_adds_change_output_when_pool_utxos_exceed_target(): +def test_payout_folds_excess_input_value_into_the_reserve_output(): + """Only a round opened before the per-round pool address can have inputs beyond winner + share + commission (the shared pool held other money). The excess goes to the reserve + address, in the same single reserve output (RND-23), not into a fourth output.""" pool_key = _key(20) pool_script = script.p2wpkh(pool_key.to_public()) pool_address = pool_script.address(network=PLM_MAINNET) @@ -74,13 +88,40 @@ def test_payout_adds_change_output_when_pool_utxos_exceed_target(): winner_share_sats=winner_share, fee_address=fee_address, commission_sats=commission, - change_address=pool_address, + reserve_address=pool_address, fee_rate_sat_vb=1, ) - assert built.change_sats == 2_000_000_000 - (winner_share + commission) + excess = 2_000_000_000 - (winner_share + commission) + assert built.change_sats == excess + assert built.reserve_sats == payout_bump_reserve(1) + excess + assert built.commission_sats == commission - payout_bump_reserve(1) parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) assert len(parsed.vout) == 3 + assert sum(o.value for o in parsed.vout) + built.fee_sats == 2_000_000_000 + + +def test_payout_leaves_a_dust_sized_excess_to_the_network_fee(): + pool_key = _key(23) + pool_script = script.p2wpkh(pool_key.to_public()) + pool_address = pool_script.address(network=PLM_MAINNET) + winner_address = script.p2wpkh(_key(24).to_public()).address(network=PLM_MAINNET) + fee_address = script.p2wpkh(_key(25).to_public()).address(network=PLM_MAINNET) + excess = DUST_LIMIT_SATS - 1 + built = build_payout_transaction( + signing_key=pool_key, + from_script=pool_script, + utxos=[Utxo("bc" * 32, 0, 1_000_000_000 + excess)], + winner_address=winner_address, + winner_share_sats=700_000_000, + fee_address=fee_address, + commission_sats=300_000_000, + reserve_address=pool_address, + fee_rate_sat_vb=1, + ) + assert built.change_sats == 0 + assert built.reserve_sats == payout_bump_reserve(1) + assert built.fee_sats == estimate_vsize(1, 3) + excess def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52 @@ -111,7 +152,7 @@ def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52 winner_share_sats=winner_share, fee_address=fee_address, commission_sats=commission, - change_address=pool_address, + reserve_address=pool_address, fee_rate_sat_vb=1, ) @@ -120,7 +161,8 @@ def test_payout_spends_more_utxos_than_a_user_transaction_may(): # B-52 assert len(parsed.vin) == participants assert built.fee_sats == estimate_vsize(participants, 3) assert built.winner_sats == winner_share - built.fee_sats - assert built.commission_sats == commission # still untouched by the fee + assert built.reserve_sats == payout_bump_reserve(participants) + assert built.commission_sats == commission - built.reserve_sats # still untouched by the network fee def test_payout_at_the_participant_cap_stays_well_inside_relay_limits(): # B-52 @@ -152,6 +194,85 @@ def test_payout_raises_when_winner_share_too_small(): winner_share_sats=winner_share, fee_address=fee_address, commission_sats=commission, - change_address=pool_address, + reserve_address=pool_address, fee_rate_sat_vb=1, ) + + +def _addresses(seed: int): + pool_key = _key(seed) + pool_script = script.p2wpkh(pool_key.to_public()) + return ( + pool_key, + pool_script, + script.p2wpkh(_key(seed + 1).to_public()).address(network=PLM_MAINNET), + script.p2wpkh(_key(seed + 2).to_public()).address(network=PLM_MAINNET), + script.p2wpkh(_key(seed + 3).to_public()).address(network=PLM_MAINNET), + ) + + +def test_the_reserve_is_sized_on_the_inputs_and_never_below_dust(): # B-79, RND-38 + assert payout_bump_reserve(1) == estimate_vsize(1, 3) * 10 + assert payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND) == estimate_vsize(MAX_PARTICIPANTS_PER_ROUND, 3) * 10 + assert payout_bump_reserve(MAX_PARTICIPANTS_PER_ROUND) > payout_bump_reserve(1) + assert payout_bump_reserve(0) == max(DUST_LIMIT_SATS, estimate_vsize(0, 3) * 10) + + +def test_payout_at_the_participant_cap_pays_the_reserve_out_of_the_platform_share(): # B-79, RND-38, RND-40 + """400 bets of the smallest net amount the default config allows: the inputs are exactly + the outputs plus the network fee, the winner's share carries the network fee, and the + platform's 30% carries the (much larger) reserve.""" + pool_key, pool_script, winner_address, fee_address, reserve_address = _addresses(60) + bet = 1_000_000_000 - estimate_vsize(MAX_TX_INPUTS, 2) + utxos = [Utxo(f"{i:064x}", 0, bet) for i in range(MAX_PARTICIPANTS_PER_ROUND)] + pool = bet * MAX_PARTICIPANTS_PER_ROUND + winner_share = pool * 70 // 100 + built = build_payout_transaction( + signing_key=pool_key, + from_script=pool_script, + utxos=utxos, + winner_address=winner_address, + winner_share_sats=winner_share, + fee_address=fee_address, + commission_sats=pool - winner_share, + reserve_address=reserve_address, + fee_rate_sat_vb=1, + ) + n = MAX_PARTICIPANTS_PER_ROUND + assert built.reserve_sats == estimate_vsize(n, 3) * 10 == 273_040 + assert built.fee_sats == estimate_vsize(n, 3) + assert built.winner_sats == winner_share - built.fee_sats + assert built.commission_sats == pool - winner_share - built.reserve_sats + parsed = Transaction.parse(bytes.fromhex(built.raw_hex)) + assert len(parsed.vin) == n and len(parsed.vout) == 3 + assert sum(o.value for o in parsed.vout) + built.fee_sats == pool + assert all(o.value >= DUST_LIMIT_SATS for o in parsed.vout) + + +def test_payout_with_one_participant_at_the_smallest_viable_pool(): # B-79, RND-40 + """The edge RND-40 guards: with one bet the platform's share is the smallest, and it has + to cover the reserve and still leave the fee address at least dust. The exact boundary + builds; one satoshi less is refused with commission_below_dust_limit.""" + pool_key, pool_script, winner_address, fee_address, reserve_address = _addresses(70) + reserve = payout_bump_reserve(1) + commission = reserve + DUST_LIMIT_SATS # the fee address gets exactly the dust limit + winner_share = 10_000 + + def build(commission_sats: int): + return build_payout_transaction( + signing_key=pool_key, + from_script=pool_script, + utxos=[Utxo("ad" * 32, 0, winner_share + commission_sats)], + winner_address=winner_address, + winner_share_sats=winner_share, + fee_address=fee_address, + commission_sats=commission_sats, + reserve_address=reserve_address, + fee_rate_sat_vb=1, + ) + + built = build(commission) + assert built.commission_sats == DUST_LIMIT_SATS and built.reserve_sats == reserve + with pytest.raises(InsufficientFundsError) as refused: + build(commission - 1) + assert refused.value.code == "commission_below_dust_limit" diff --git a/tests/unit/test_rounds_route.py b/tests/unit/test_rounds_route.py index fdac195..c30069b 100644 --- a/tests/unit/test_rounds_route.py +++ b/tests/unit/test_rounds_route.py @@ -296,3 +296,23 @@ async def test_current_round_exposes_its_own_pool_address(client): resp = await ac.get("/rounds/current") assert resp.json()["pool_address"] == derive_pool_address(5) + + +async def test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_round(client): # B-79 (RND-40) + from sqlalchemy import select + + from app.db.models import RoundConfig + + ac, session_factory = client + async with session_factory() as session: + session.add( + RoundConfig(fee_address="plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n", bet_amount_sats=5_000) + ) + await session.commit() + body = (await ac.get("/rounds/current")).json() + assert body["lottery_configured"] is False and body["round_id"] is None + + async with session_factory() as session: + (await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000 + await session.commit() + assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True diff --git a/tests/unit/test_rounds_service.py b/tests/unit/test_rounds_service.py index ae43aa6..1f04982 100644 --- a/tests/unit/test_rounds_service.py +++ b/tests/unit/test_rounds_service.py @@ -236,9 +236,88 @@ async def test_a_round_in_progress_survives_the_fee_address_being_cleared(sessio def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66 from app.rounds.service import rounds_can_open - assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True - assert rounds_can_open(RoundConfig(fee_address="")) is False - assert rounds_can_open(RoundConfig(fee_address=" ")) is False + # B-79: the payability check also reads the amounts, which are column defaults only once + # a row is flushed — so a bare object spells them out. + amounts = {"bet_amount_sats": 1_000_000_000, "fee_rate_sat_vb": 1} + assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS, **amounts)) is True + assert rounds_can_open(RoundConfig(fee_address="", **amounts)) is False + assert rounds_can_open(RoundConfig(fee_address=" ", **amounts)) is False + + +# --- B-79 (RND-40): a configuration under which a round cannot be paid opens no round --- + + +def test_payout_config_problem_at_both_extremes(): + """The default config is payable; the boundary bet amounts are found by the same formula + as the spec: net = bet - fee(50 inputs, 2 outputs) at the rate, checked for 1 participant + and for MAX_PARTICIPANTS_PER_ROUND.""" + from app.rounds.service import payout_config_problem, winner_share + from app.wallet.psbt_builder import ( + DUST_LIMIT_SATS, + MAX_PARTICIPANTS_PER_ROUND, + MAX_TX_INPUTS, + estimate_vsize, + payout_bump_reserve, + ) + + assert payout_config_problem(1_000_000_000, 1) is None + + def feasible(bet: int, rate: int, n: int) -> bool: + pool = n * (bet - estimate_vsize(MAX_TX_INPUTS, 2) * rate) + share = winner_share(pool) + return ( + share - estimate_vsize(n, 3) * rate >= DUST_LIMIT_SATS + and pool - share - payout_bump_reserve(n) >= DUST_LIMIT_SATS + ) + + # binary-search the smallest viable bet at 1 sat/vB for each extreme, independently of + # the function under test, then check it is exactly where payout_config_problem flips + def smallest(n: int, rate: int) -> int: + lo, hi = 1, 10_000_000 + while lo < hi: + mid = (lo + hi) // 2 + lo, hi = (lo, mid) if feasible(mid, rate, n) else (mid + 1, hi) + return lo + + for rate in (1, 10): + edge = max(smallest(1, rate), smallest(MAX_PARTICIPANTS_PER_ROUND, rate)) + assert payout_config_problem(edge, rate) is None + assert payout_config_problem(edge - 1, rate) is not None + # each extreme can be the one that fails: one participant is bounded by the platform share + # (reserve + dust), the maximum by the reserve that grows with the inputs + assert smallest(1, 1) != smallest(MAX_PARTICIPANTS_PER_ROUND, 1) + assert payout_config_problem(1, 1) is not None # a bet that does not even pay its own fee + + +async def test_no_round_opens_while_the_config_could_not_pay_one(session_factory): + async with session_factory() as session: + config = (await session.scalars(select(RoundConfig))).one() + config.bet_amount_sats = 5_000 # fee address set, but the platform's 30% cannot cover the reserve + await session.commit() + + async with session_factory() as session: + assert await open_new_round_if_needed(session) is None + async with session_factory() as session: + assert (await session.scalars(select(Round))).all() == [] + + async with session_factory() as session: + (await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000 + await session.commit() + async with session_factory() as session: + round_ = await open_new_round_if_needed(session) + await session.commit() + assert round_ is not None and round_.status == "open" + + +async def test_a_round_in_progress_survives_an_unpayable_config(session_factory): + """Same scope as every other prerequisite: it only stops the *next* round.""" + async with session_factory() as session: + session.add(Round(status="open")) + (await session.scalars(select(RoundConfig))).one().fee_rate_sat_vb = 5_000 + await session.commit() + async with session_factory() as session: + returned = await open_new_round_if_needed(session) + assert returned is not None and returned.status == "open" # --- B-61: a round runs by the timing it opened with, not by the live config ------ diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index a02584a..ae4bf70 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -165,6 +165,40 @@ async def test_trigger_payout_persists_before_broadcasting(payout_session_factor assert "payout_sent" in events +async def test_trigger_payout_always_carries_a_bump_reserve_to_the_reserve_address(payout_session_factory): + """B-79 (RND-23, RND-24, RND-38, DAT-22, DAT-73): the pool holds exactly the round's bets + and the payout spends them all, yet the transaction has three outputs — winner, fee + address, reserve at m/84'/746'/0'/1/0 — and the round records the reserve before the + broadcast. Nothing is created or lost to the satoshi.""" + from app.wallet.psbt_builder import estimate_vsize, payout_bump_reserve + + await _seed_paying_out_round(payout_session_factory) + entries = [{"tx_hash": "34" * 32, "tx_pos": 0, "height": 10, "value": _POOL_AMOUNT_SATS}] # no excess + client = FakePayoutClient(entries) + scheduler = RoundScheduler(payout_session_factory, FakePayoutListener(client)) + + await scheduler._trigger_payout(1) + + async with payout_session_factory() as session: + round_ = await session.get(Round, 1) + pending = (await session.scalars(select(PendingTransaction))).one() + tx = Transaction.parse(bytes.fromhex(pending.raw_tx_hex)) + from app.wallet.plm_network import PLM_MAINNET + + addresses = [o.script_pubkey.address(network=PLM_MAINNET) for o in tx.vout] + assert addresses == [_WINNER_ADDRESS, _FEE_ADDRESS, derive_pool_address()] # reserve = 1/0 + reserve = payout_bump_reserve(1) + network_fee = estimate_vsize(1, 3) + winner_share = _POOL_AMOUNT_SATS * 70 // 100 + assert round_.bump_reserve_sats == reserve == tx.vout[2].value + assert round_.winner_amount_sats == winner_share - network_fee == tx.vout[0].value + assert round_.fee_amount_sats == _POOL_AMOUNT_SATS - winner_share - reserve == tx.vout[1].value + assert ( + round_.winner_amount_sats + round_.fee_amount_sats + round_.bump_reserve_sats + network_fee + == _POOL_AMOUNT_SATS + ) + + async def test_trigger_payout_pays_a_round_with_more_participants_than_max_tx_inputs( payout_session_factory, ): # B-52