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>
319 lines
12 KiB
Python
319 lines
12 KiB
Python
import pytest
|
|
from cryptography.fernet import Fernet
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.config import settings
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
|
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
|
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
|
|
|
import app.wallet.hd as hd
|
|
|
|
hd._account_key = None
|
|
hd.generate_master_key()
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.db import base as db_base
|
|
|
|
import app.db.models # noqa: F401
|
|
|
|
db_base.engine = create_async_engine(settings.database_url)
|
|
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
|
|
|
from app.db import session as db_session
|
|
|
|
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
|
|
|
async with db_base.engine.begin() as conn:
|
|
await conn.run_sync(db_base.Base.metadata.create_all)
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from app.api.routes.rounds import router as rounds_router
|
|
from app.auth.routes import router as auth_router
|
|
from app.electrum.listener import ElectrumListener
|
|
|
|
app = FastAPI()
|
|
app.include_router(auth_router)
|
|
app.include_router(rounds_router)
|
|
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac, db_base.AsyncSessionLocal
|
|
|
|
await db_base.engine.dispose()
|
|
|
|
|
|
async def _register(ac, username):
|
|
resp = await ac.post("/auth/register", json={"username": username, "password": "hunter2hunter"})
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
return data["access_token"], data["user_id"] if "user_id" in data else None
|
|
|
|
|
|
async def test_user_played_true_only_for_participants(client):
|
|
ac, session_factory = client
|
|
from app.db.models import Round, RoundConfig, RoundParticipant, User
|
|
|
|
player_token, _ = await _register(ac, "player")
|
|
spectator_token, _ = await _register(ac, "spectator")
|
|
|
|
async with session_factory() as session:
|
|
from sqlalchemy import select
|
|
|
|
session.add(RoundConfig(fee_address="pool-fee-address"))
|
|
player = (await session.scalars(select(User).where(User.username == "player"))).one()
|
|
round_ = Round(status="paying_out", winner_user_id=player.id, winner_amount_sats=123)
|
|
session.add(round_)
|
|
await session.flush()
|
|
session.add(
|
|
RoundParticipant(
|
|
round_id=round_.id,
|
|
user_id=player.id,
|
|
bet_amount_sats=1_000_000_000,
|
|
bet_txid="a" * 64,
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
resp = await ac.get("/rounds/current", headers={"Authorization": f"Bearer {player_token}"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["user_played"] is True
|
|
|
|
resp = await ac.get("/rounds/current", headers={"Authorization": f"Bearer {spectator_token}"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["user_played"] is False
|
|
|
|
resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view
|
|
assert resp.status_code == 200
|
|
assert resp.json()["user_played"] is False
|
|
|
|
|
|
async def test_jackpot_comes_from_the_participants_actual_bets(client):
|
|
"""B-11: the jackpot was participant_count * the *current* bet_amount_sats, which
|
|
overstated it (each stored bet is already net of that bet's network fee) and
|
|
silently rewrote the advertised jackpot of a round in progress whenever an
|
|
operator edited the bet amount."""
|
|
from sqlalchemy import select
|
|
|
|
from app.db.models import Round, RoundConfig, RoundParticipant
|
|
|
|
ac, session_factory = client
|
|
|
|
async with session_factory() as session:
|
|
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
|
session.add(Round(id=50, status="open"))
|
|
await session.flush()
|
|
# Two bets that actually paid 999_800_000 each (fee deducted), not 1_000_000_000.
|
|
session.add(
|
|
RoundParticipant(round_id=50, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
|
)
|
|
session.add(
|
|
RoundParticipant(round_id=50, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="confirmed")
|
|
)
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
assert body["participant_count"] == 2
|
|
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
|
|
|
|
# Changing the configured bet amount must not move a running round's jackpot.
|
|
async with session_factory() as session:
|
|
config = (await session.scalars(select(RoundConfig))).one()
|
|
config.bet_amount_sats = 5_000_000_000
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
|
|
|
|
|
|
async def test_advertised_jackpot_covers_only_the_bets_that_will_be_paid(client): # B-65
|
|
"""The draw picks from confirmed participants and the payout spends only their
|
|
sats, so counting every participant row advertised a jackpot bigger than the one
|
|
that would actually be paid — and let a player appear in the count and then
|
|
vanish again if their bet was abandoned. The confirmed figures are the headline
|
|
ones; what's in flight is reported alongside, never folded in."""
|
|
from app.db.models import Round, RoundConfig, RoundParticipant
|
|
|
|
ac, session_factory = client
|
|
|
|
async with session_factory() as session:
|
|
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
|
session.add(Round(id=60, status="open"))
|
|
await session.flush()
|
|
session.add(
|
|
RoundParticipant(round_id=60, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
|
)
|
|
# One mid-broadcast and one written but not yet broadcast: both in flight,
|
|
# neither drawn from nor spent by the payout as things stand.
|
|
session.add(
|
|
RoundParticipant(round_id=60, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="broadcast")
|
|
)
|
|
session.add(
|
|
RoundParticipant(round_id=60, user_id=3, bet_amount_sats=999_800_000, bet_txid="c", status="building")
|
|
)
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
|
|
assert body["participant_count"] == 1
|
|
assert body["jackpot_sats"] == 999_800_000 * 70 // 100
|
|
# Inclusive, like pending_balance_sats — not a delta.
|
|
assert body["pending_participant_count"] == 3
|
|
assert body["pending_jackpot_sats"] == (999_800_000 * 3) * 70 // 100
|
|
assert body["has_pending_bets"] is True
|
|
|
|
|
|
async def test_no_pending_bets_reported_once_every_bet_has_confirmed(client): # B-65
|
|
from app.db.models import Round, RoundConfig, RoundParticipant
|
|
|
|
ac, session_factory = client
|
|
|
|
async with session_factory() as session:
|
|
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
|
|
session.add(Round(id=61, status="open"))
|
|
await session.flush()
|
|
session.add(
|
|
RoundParticipant(round_id=61, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
|
|
)
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
|
|
assert body["has_pending_bets"] is False
|
|
assert body["pending_participant_count"] == body["participant_count"] == 1
|
|
assert body["pending_jackpot_sats"] == body["jackpot_sats"]
|
|
|
|
|
|
async def test_lottery_configured_flags_a_missing_fee_address(client): # B-66
|
|
"""The frontend has to tell "the next round is coming" apart from "nothing is
|
|
coming until the operator finishes setting this up" — the banner says different
|
|
things, and only one of them is worth waiting for."""
|
|
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=""))
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
assert body["lottery_configured"] is False
|
|
assert body["lottery_paused"] is False # not a pause: a prerequisite that isn't met
|
|
assert body["round_id"] is None # and indeed no round was opened
|
|
|
|
async with session_factory() as session:
|
|
(await session.scalars(select(RoundConfig))).one().fee_address = (
|
|
"plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
|
)
|
|
await session.commit()
|
|
|
|
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
|
|
|
|
|
|
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
|
|
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
|
|
needs draw_waiting_since to show "still waiting" instead of implying a bounded
|
|
countdown. It must not leak for any other status, where it's meaningless."""
|
|
from datetime import datetime, timezone
|
|
|
|
from app.db.models import Round, RoundConfig
|
|
|
|
ac, session_factory = client
|
|
|
|
started_at = datetime(2026, 7, 27, 10, 0, 0)
|
|
async with session_factory() as session:
|
|
session.add(RoundConfig(fee_address=""))
|
|
session.add(Round(id=60, status="drawing", drawing_started_at=started_at))
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00"
|
|
|
|
async with session_factory() as session:
|
|
from sqlalchemy import select
|
|
|
|
round_ = (await session.scalars(select(Round).where(Round.id == 60))).one()
|
|
round_.status = "paying_out"
|
|
await session.commit()
|
|
|
|
body = (await ac.get("/rounds/current")).json()
|
|
assert body["draw_waiting_since"] is None
|
|
|
|
|
|
async def test_unhandled_errors_use_the_structured_detail_shape(client):
|
|
"""B-24: the catch-all handler answered with a bare-string `detail`, while
|
|
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
|
|
had to special-case exactly the responses they understand least."""
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import log_unhandled_exception
|
|
|
|
app = FastAPI()
|
|
app.add_exception_handler(Exception, log_unhandled_exception)
|
|
|
|
@app.get("/boom")
|
|
async def boom():
|
|
raise RuntimeError("secret internal detail")
|
|
|
|
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
resp = await ac.get("/boom")
|
|
|
|
assert resp.status_code == 500
|
|
detail = resp.json()["detail"]
|
|
assert detail["code"] == "internal_error"
|
|
assert detail["message"] == "internal server error"
|
|
assert detail["params"] == {}
|
|
# The exception text belongs in logs/app.log, never in the response body.
|
|
assert "secret internal detail" not in resp.text
|
|
|
|
|
|
async def test_current_round_exposes_its_own_pool_address(client):
|
|
"""API-23 / RND-35 / B-80: GET /rounds/current carries the round's pool address, so
|
|
anyone can verify its money on an explorer; null when there is no round."""
|
|
from app.db.models import Round
|
|
from app.wallet.hd import derive_pool_address
|
|
|
|
ac, session_factory = client
|
|
|
|
resp = await ac.get("/rounds/current")
|
|
assert resp.json()["pool_address"] is None
|
|
|
|
async with session_factory() as session:
|
|
session.add(Round(id=5, status="open", pool_address=derive_pool_address(5)))
|
|
await session.commit()
|
|
|
|
resp = await ac.get("/rounds/current")
|
|
assert resp.json()["pool_address"] == derive_pool_address(5)
|
|
|
|
|
|
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
|