The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
419 lines
16 KiB
Python
419 lines
16 KiB
Python
import pytest
|
|
from cryptography.fernet import Fernet
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.config import settings
|
|
|
|
# A real PLM bech32 address: PUT /admin/config now validates fee_address, since a
|
|
# foreign-chain address there would send every round's commission to a script
|
|
# nobody can spend (B-05).
|
|
_VALID_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
|
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
|
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 create_async_engine
|
|
|
|
from app.db import base as db_base
|
|
|
|
db_base.engine = create_async_engine(settings.database_url)
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
|
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
|
|
|
# app.db.session did `from app.db.base import AsyncSessionLocal` at its own
|
|
# first import, which only copies the reference as it was at that moment —
|
|
# reassigning db_base.AsyncSessionLocal above doesn't reach it. get_session()
|
|
# looks up its module global at call time, so rebinding it here (every test)
|
|
# keeps it pointed at *this* test's engine instead of whichever ran first.
|
|
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 app.api.routes.admin import router as admin_router
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
app.include_router(admin_router)
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
await db_base.engine.dispose()
|
|
|
|
|
|
async def test_admin_requires_token(client):
|
|
resp = await client.get("/admin/config")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_rejects_wrong_token(client):
|
|
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_rejects_non_ascii_token_with_403_not_500(client):
|
|
"""B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which
|
|
used to bubble up as a 500 instead of the expected 403. httpx encodes str
|
|
header values as ASCII client-side, so the raw UTF-8 bytes are passed
|
|
directly to reproduce what a real non-ASCII header on the wire looks like."""
|
|
resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_reads_and_updates_config(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["fee_address"] == ""
|
|
|
|
resp = await client.put(
|
|
"/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS, "bet_amount_sats": 500_000_000}
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["fee_address"] == _VALID_FEE_ADDRESS
|
|
assert body["bet_amount_sats"] == 500_000_000
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.json()["fee_address"] == _VALID_FEE_ADDRESS
|
|
|
|
|
|
async def test_admin_can_pause_and_resume_the_lottery(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.json()["paused"] is False
|
|
|
|
resp = await client.post("/admin/pause", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["paused"] is True
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.json()["paused"] is True
|
|
|
|
resp = await client.post("/admin/resume", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["paused"] is False
|
|
|
|
|
|
async def test_admin_pause_requires_token(client):
|
|
resp = await client.post("/admin/pause")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_lists_users(client):
|
|
from app.db import base as db_base
|
|
from app.db.models import User
|
|
from app.wallet.hd import derive_user_address
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(
|
|
User(
|
|
username="alice",
|
|
password_hash="unused",
|
|
derivation_index=0,
|
|
address=derive_user_address(0),
|
|
cached_balance_sats=1_000_000_000,
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/users", headers=headers)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert len(body) == 1
|
|
assert body[0]["username"] == "alice"
|
|
assert body[0]["balance_sats"] == 1_000_000_000
|
|
|
|
|
|
async def test_admin_exports_user_privkey(client):
|
|
from embit.ec import PrivateKey
|
|
|
|
from app.db import base as db_base
|
|
from app.db.models import User
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
user = User(
|
|
username="bob",
|
|
password_hash="unused",
|
|
derivation_index=1,
|
|
address=derive_user_address(1),
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
user_id = user.id
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get(f"/admin/users/{user_id}/privkey", headers=headers)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
|
|
expected_wif = PrivateKey(derive_user_key(1).secret, compressed=True, network=PLM_MAINNET).wif(
|
|
network=PLM_MAINNET
|
|
)
|
|
assert body["wif"] == expected_wif
|
|
assert body["address"] == derive_user_address(1)
|
|
|
|
|
|
async def test_admin_privkey_404_for_unknown_user(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/users/999/privkey", headers=headers)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_admin_resets_user_password(client):
|
|
from app.auth.security import hash_password, verify_password
|
|
from app.db import base as db_base
|
|
from app.db.models import User
|
|
from app.wallet.hd import derive_user_address
|
|
|
|
old_hash = hash_password("original-password")
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
user = User(
|
|
username="carol",
|
|
password_hash=old_hash,
|
|
derivation_index=2,
|
|
address=derive_user_address(2),
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
user_id = user.id
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.post(f"/admin/users/{user_id}/reset-password", headers=headers)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["username"] == "carol"
|
|
new_password = body["new_password"]
|
|
assert new_password and new_password != "original-password"
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
refreshed = await session.get(User, user_id)
|
|
assert refreshed.password_hash != old_hash
|
|
assert verify_password(new_password, refreshed.password_hash)
|
|
assert not verify_password("original-password", refreshed.password_hash)
|
|
# B-34: the reset must bump token_version so a session opened before
|
|
# the reset (e.g. an attacker who had the old password) is evicted
|
|
# immediately rather than staying valid until the JWT naturally expires.
|
|
assert refreshed.token_version == 1
|
|
|
|
|
|
async def test_admin_reset_password_requires_token(client):
|
|
resp = await client.post("/admin/users/1/reset-password")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_reset_password_404_for_unknown_user(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.post("/admin/users/999/reset-password", headers=headers)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_config_update_is_audit_logged(client):
|
|
"""B-10: /pause and /resume were logged but a config change wasn't, so the most
|
|
sensitive setting in the system — fee_address, where 30% of every pool goes —
|
|
could be changed without leaving any trace."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
|
|
|
resp = await client.get("/admin/audit-log", headers=headers)
|
|
entries = [e for e in resp.json() if e["event_type"] == "config_updated"]
|
|
assert len(entries) == 1
|
|
assert entries[0]["payload"]["fee_address"] == {"from": "", "to": _VALID_FEE_ADDRESS}
|
|
|
|
|
|
async def test_config_update_without_changes_logs_nothing(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
|
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
|
|
|
resp = await client.get("/admin/audit-log", headers=headers)
|
|
assert len([e for e in resp.json() if e["event_type"] == "config_updated"]) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"fee_address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"}, # valid bech32, wrong chain
|
|
{"fee_address": "plm1qbogus"}, # right HRP, broken checksum
|
|
{"fee_address": "garbage"},
|
|
{"fee_rate_sat_vb": 0}, # fee-less txs are never relayed: everything would stall
|
|
{"round_duration_seconds": 0}, # a round that expires the instant it opens
|
|
{"bet_amount_sats": -1},
|
|
{"rbf_timeout_seconds": 1},
|
|
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
|
|
{"draw_confirmations": 21},
|
|
],
|
|
)
|
|
async def test_config_rejects_unusable_values(client, payload):
|
|
"""B-05: every one of these was accepted before. The bc1 case is the worst — it
|
|
parses as a valid witness program, so each round's commission would be broadcast
|
|
to a script nobody holds the key for."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.put("/admin/config", headers=headers, json=payload)
|
|
assert resp.status_code == 422
|
|
|
|
# and nothing was written
|
|
current = (await client.get("/admin/config", headers=headers)).json()
|
|
for field, value in payload.items():
|
|
assert current[field] != value
|
|
|
|
|
|
async def test_pause_cannot_be_toggled_through_the_config_endpoint(client):
|
|
"""B-10: `paused` used to be settable here, bypassing the audit-logged
|
|
pause/resume endpoints."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.put("/admin/config", headers=headers, json={"paused": True})
|
|
assert resp.status_code in (200, 422) # ignored or refused, but never applied
|
|
assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"])
|
|
@pytest.mark.parametrize("bad_limit", [0, -1, 501])
|
|
async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit):
|
|
"""B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an
|
|
unvalidated limit could dump the entire table in one response."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_admin_list_rounds_respects_limit(client):
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add_all([Round(status="closed") for _ in range(3)])
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
|
|
async def test_admin_audit_log_respects_limit(client):
|
|
from app.db import base as db_base
|
|
from app.audit.log import write_audit_log
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
for _ in range(3):
|
|
await write_audit_log(session, "test_event", {})
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
|
|
async def _make_pending_transaction(session, *, kind="bet", status="pending"):
|
|
from app.db.models import PendingTransaction
|
|
import secrets as _secrets
|
|
|
|
tx = PendingTransaction(
|
|
kind=kind,
|
|
current_txid=_secrets.token_hex(32),
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex="00",
|
|
status=status,
|
|
)
|
|
session.add(tx)
|
|
return tx
|
|
|
|
|
|
async def test_admin_pending_transactions_respects_limit(client):
|
|
from app.db import base as db_base
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
for _ in range(3):
|
|
await _make_pending_transaction(session)
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
|
|
async def test_admin_pending_transactions_status_filter(client):
|
|
from app.db import base as db_base
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
await _make_pending_transaction(session, status="pending")
|
|
await _make_pending_transaction(session, status="confirmed")
|
|
await _make_pending_transaction(session, status="failed")
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get(
|
|
"/admin/pending-transactions", headers=headers, params={"status": "confirmed"}
|
|
)
|
|
assert resp.status_code == 200
|
|
entries = resp.json()
|
|
assert len(entries) == 1
|
|
assert entries[0]["status"] == "confirmed"
|
|
|
|
|
|
async def test_admin_list_rounds_shows_each_rounds_pool_address(client):
|
|
"""API-36 / B-80: pool_address per round; null for a row without one."""
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
from app.wallet.hd import derive_pool_address
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(Round(id=1, status="closed", pool_address=derive_pool_address(1)))
|
|
session.add(Round(id=2, status="closed"))
|
|
await session.commit()
|
|
|
|
resp = await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})
|
|
by_id = {row["id"]: row for row in resp.json()}
|
|
assert by_id[1]["pool_address"] == derive_pool_address(1)
|
|
assert by_id[2]["pool_address"] is None
|
|
|
|
|
|
async def test_draw_confirmations_default_to_3_and_are_editable_within_1_to_20(client):
|
|
"""B-73 (DAT-62, API-30, API-31): the number of blocks the draw waits for on its block."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
assert (await client.get("/admin/config", headers=headers)).json()["draw_confirmations"] == 3
|
|
|
|
resp = await client.put("/admin/config", headers=headers, json={"draw_confirmations": 6})
|
|
assert resp.status_code == 200 and resp.json()["draw_confirmations"] == 6
|
|
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 1})).status_code == 200
|
|
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 20})).status_code == 200
|
|
|
|
|
|
async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client):
|
|
"""B-73 (API-36): server_seed_hash and draw_height are listed, server_seed is not."""
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(Round(status="closed", server_seed_hash="ab" * 32, server_seed="cd" * 32, draw_height=77))
|
|
await session.commit()
|
|
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])
|