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:
1 parent
473d541ab5
commit
daa85cf4f0
27 files changed
+684
-139
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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})")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 ------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user