Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ccd15807ed
commit
8b182ac8b6
28 files changed
+520
-121
No files matched your search
@@ -512,3 +512,51 @@ async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_ch
|
||||
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
|
||||
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
|
||||
assert resumed and '"payout_blocked"' in resumed[0].payload_json
|
||||
|
||||
|
||||
# --- B-95 (DAT-62, API-31): the cap on a round's pool ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_max_round_pool_sats_is_configurable_and_bounded(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["max_round_pool_sats"] == 0
|
||||
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
||||
assert (await put({"max_round_pool_sats": 50_000_000_000})).json()["max_round_pool_sats"] == 50_000_000_000
|
||||
assert (await put({"max_round_pool_sats": 0})).json()["max_round_pool_sats"] == 0 # 0 switches it off
|
||||
assert (await put({"max_round_pool_sats": 4_000_000_000_000_000})).status_code == 200
|
||||
assert (await put({"max_round_pool_sats": 4_000_000_000_000_001})).status_code == 422
|
||||
assert (await put({"max_round_pool_sats": -1})).status_code == 422
|
||||
|
||||
|
||||
async def test_a_cap_below_the_price_of_one_bet_is_refused_whichever_field_makes_it(client):
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
||||
resp = await put({"max_round_pool_sats": 999_999_999}) # the bet costs 1_000_000_000
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
|
||||
assert (await put({"max_round_pool_sats": 1_000_000_000})).status_code == 200 # exactly one bet fits
|
||||
resp = await put({"bet_amount_sats": 2_000_000_000}) # now the bet would exceed the stored cap
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
|
||||
assert (await put({"bet_amount_sats": 2_000_000_000, "max_round_pool_sats": 6_000_000_000})).status_code == 200
|
||||
assert (await client.get("/admin/config", headers=headers)).json()["bet_amount_sats"] == 2_000_000_000
|
||||
|
||||
|
||||
async def test_a_fee_rate_is_checked_against_the_bet_amount_of_the_round_in_progress(client):
|
||||
"""B-95 with RND-40: the round in progress is paid with the cost it opened with, which can be
|
||||
lower than the one configured now, so the fee rate has to leave that one payable too."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db.models import Round
|
||||
|
||||
headers = {"X-Admin-Token": "test-admin-token"}
|
||||
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
||||
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
assert (await put({"bet_amount_sats": 100_000_000})).status_code == 200
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
session.add(Round(status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=50_000)) # opened cheap
|
||||
await session.commit()
|
||||
# 14 sat/vB is fine for the configured 1 PLM (and for 100 PLM) but not for the 50_000 sat round
|
||||
resp = await put({"fee_rate_sat_vb": 14})
|
||||
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
|
||||
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200
|
||||
Reference in new issue
Block a user