Give every payout a bump reserve output and refuse configs that cannot pay a round (B-79, RND-21, RND-23, RND-24, RND-27, RND-38, RND-40, DAT-73, API-31, API-36, TX-11, ADR-17, ADR-29)

The pool normally holds exactly the round's bet outputs and the payout spent them all for
exactly the pool amount, so it had no change output: bump_fee could never accelerate it,
and a payout stuck in a mempool (never abandoned while a server knows it) kept the round in
`paying_out` and the whole lottery stopped.

- build_payout_transaction always has three outputs: winner, fee address, and a bump reserve
  of max(DUST_LIMIT_SATS, estimate_vsize(inputs, 3) * 10 sat/vB) paid to the reserve address
  m/84'/746'/0'/1/0. The reserve comes out of the platform's 30%, the network fee still out
  of the winner's 70%. Input value beyond winner share + commission (only rounds opened
  before the per-round pool address) is folded into the reserve output if >= dust, left to
  the network fee otherwise. `change_address` is replaced by `reserve_address`.
- `rounds.bump_reserve_sats` (Alembic e5a91c3d7b26, plain add_column) is written with the
  payout intent before the broadcast; /admin/rounds returns it.
- bump_fee takes the increase from the reserve output for a payout
  (_find_reducible_output; a payout already in flight with a change to the pool still falls
  back to that), never from the winner or fee-address outputs.
- payout_config_problem / rounds_can_open (RND-40): a bet amount and fee rate under which a
  round of 1 or of MAX_PARTICIPANTS_PER_ROUND participants could not cover the network fee,
  the reserve and dust outputs opens no round (lottery_configured=false); PUT /admin/config
  refuses such a bet amount or fee rate with 422 config_not_payable, judged on the resulting
  values, since the payout reads the fee rate live.

Ambiguity resolved: the reserve output and any excess input are one output (RND-23 says one
output to the reserve address), and bump_reserve_sats records that output's initial value.
DAT-22's "abandon clears all amounts" stays with B-96.

Tests changed because the spec changed: the four test_payout_builder tests asserted the old
two/three-output shape with change to the pool and the fee address receiving the whole 30%;
the whitespace fee_address test now spells out the amounts rounds_can_open reads. New: reserve
sizing, payout with 1 and with 400 participants (inputs = outputs + fee to the satoshi),
exact commission/reserve/dust boundary, excess folded or left to the fee, a real payout bumped
out of its reserve with the other outputs byte-identical and bumped five times, the old
reserve-less shape still raising RbfError, the scheduler output addresses and recorded
amounts, payability boundaries found independently by bisection, no round / unconfigured flag
for an unpayable config, a round in progress surviving it, PUT /admin/config refusals and the
migration column. Mutation-checked: dropping the reserve lookup in bump_fee and the payability
check in rounds_can_open each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-16 13:55:51 +02:00
1 parent 473d541ab5
commit daa85cf4f0
27 files changed
+684 -139

No files matched your search

-15
View File
@@ -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)
+6 -6
View File
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
## Project status
All 10 stages of the original build order are code-complete and unit-tested — 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/<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.
- *Payout* is signed with the round's own pool key — every round has its own pool address `m/84'/746'/0'/2/<round_id>`, stored in `rounds.pool_address` when the round opens (B-80; `derive_pool_address(round_id)`, `derive_round_pool_key`), and bets pay it, the payout reads and spends only its coins, and a withdrawal to it (or to the reserve address `1/0`, or to `fee_address`) is refused with `withdrawal_to_platform_address`. Rounds opened before B-80 keep the old shared `1/0` address as their pool; it is now the *reserve* address. The payout's **network fee comes out of the winner's 70%**; the platform's 30% pays the **bump reserve** (B-79, RND-38: always a third output, to the reserve address `1/0`, recorded in `rounds.bump_reserve_sats`) and the remainder goes to `fee_address`. The reserve is what makes the payout bumpable — it normally spends exactly the round's bet outputs and so has no change — and `bump_fee` shrinks it (`_find_reducible_output`: the reserve output for a payout, the user's change for a bet/withdrawal), never the winner's or the fee address's output. Same timeout → RBF → rebroadcast pattern.
- *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.
+1 -1
View File
@@ -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
+19
View File
@@ -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
]
+3
View File
@@ -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
+3 -2
View File
@@ -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",
+39 -7
View File
@@ -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:
+15 -2
View File
@@ -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")
+48 -20
View File
@@ -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,
)
@@ -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`
@@ -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`
+8 -3
View File
@@ -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
+1 -1
View File
@@ -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/<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) |
| `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
+15 -28
View File
@@ -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
+1 -1
View File
@@ -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
+13 -15
View File
@@ -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/<id>` 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
+5 -5
View File
@@ -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` ·
+3 -3
View File
@@ -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`.
+3 -7
View File
@@ -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
@@ -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})")
+50
View File
@@ -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
+131
View File
@@ -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)
+7
View File
@@ -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
+133 -12
View File
@@ -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"
+20
View File
@@ -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
+82 -3
View File
@@ -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 ------
+34
View File
@@ -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