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>
199 lines
10 KiB
Python
199 lines
10 KiB
Python
"""B-97 (DAT-75, DAT-56, ARC-62, ARC-64): the Alembic chain is executed, not assumed.
|
|
|
|
The two expression indexes that carry invariants — `ix_rounds_single_active` ("at
|
|
most one active round", B-09) and `ix_users_username_lower` ("usernames are
|
|
case-insensitive", B-57) — are invisible to autogenerate, and `batch_alter_table`
|
|
on SQLite rebuilds the table and drops them with only a warning. Every other test
|
|
builds its schema with `Base.metadata.create_all`, so a migration that lost one of
|
|
them would reach production unnoticed. This one runs `alembic upgrade head` on an
|
|
empty database, exactly as the container does at startup.
|
|
|
|
Alembic runs in a subprocess: env.py calls `asyncio.run` and `fileConfig`, neither of
|
|
which belongs inside the pytest process.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def migrated_db(tmp_path_factory) -> Path:
|
|
db_path = tmp_path_factory.mktemp("migrations") / "migrated.db"
|
|
env = {**os.environ, "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}"}
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
|
cwd=_ROOT,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
assert result.returncode == 0, f"alembic upgrade head failed:\n{result.stderr[-3000:]}"
|
|
return db_path
|
|
|
|
|
|
def _index_sql(db_path: Path, name: str) -> str | None:
|
|
with sqlite3.connect(db_path) as conn:
|
|
row = conn.execute("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", (name,)).fetchone()
|
|
return re.sub(r"\s+", " ", row[0]).lower() if row else None
|
|
|
|
|
|
def test_the_expression_indexes_survive_the_whole_migration_chain(migrated_db):
|
|
"""DAT-75 / ARC-62 / B-97: both indexes exist, with their definition, at head."""
|
|
single_active = _index_sql(migrated_db, "ix_rounds_single_active")
|
|
assert single_active is not None, "ix_rounds_single_active was lost by a migration"
|
|
assert "unique index" in single_active
|
|
assert "on rounds ((1))" in single_active
|
|
for status in ("open", "closing", "drawing", "paying_out"):
|
|
assert f"'{status}'" in single_active
|
|
|
|
username_lower = _index_sql(migrated_db, "ix_users_username_lower")
|
|
assert username_lower is not None, "ix_users_username_lower was lost by a migration"
|
|
assert "unique index" in username_lower
|
|
assert "lower(username)" in username_lower
|
|
|
|
|
|
def test_the_migrated_database_still_enforces_both_invariants(migrated_db):
|
|
"""DAT-75 / B-97: a second active round and a case-variant username are refused."""
|
|
with sqlite3.connect(migrated_db) as conn:
|
|
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('open', '2026-01-01 00:00:00')")
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closing', '2026-01-01 00:00:00')")
|
|
# A closed round is not active, so any number of them may coexist.
|
|
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
|
|
|
|
columns = "(username, password_hash, derivation_index, address, cached_balance_sats, token_version, created_at)"
|
|
conn.execute(f"INSERT INTO users {columns} VALUES ('Alice', 'x', 0, 'plm1qa', 0, 0, '2026-01-01 00:00:00')")
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
conn.execute(f"INSERT INTO users {columns} VALUES ('aLICE', 'x', 1, 'plm1qb', 0, 0, '2026-01-01 00:00:00')")
|
|
|
|
|
|
def _alembic(db_path: Path, *args: str) -> None:
|
|
env = {**os.environ, "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}"}
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "alembic", *args], cwd=_ROOT, env=env, capture_output=True, text=True, timeout=120
|
|
)
|
|
assert result.returncode == 0, f"alembic {' '.join(args)} failed:\n{result.stderr[-3000:]}"
|
|
|
|
|
|
def test_pending_transaction_txids_table_matches_the_model(migrated_db):
|
|
"""B-77 (DAT-70): the migrated table has the model's columns, the unique txid and the
|
|
index on the owning row."""
|
|
from app.db.models import PendingTransactionTxid
|
|
|
|
with sqlite3.connect(migrated_db) as conn:
|
|
columns = {row[1]: row for row in conn.execute("PRAGMA table_info(pending_transaction_txids)")}
|
|
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name = 'pending_transaction_txids'").fetchone()[0]
|
|
indexes = {row[1] for row in conn.execute("PRAGMA index_list(pending_transaction_txids)")}
|
|
assert set(columns) == {c.name for c in PendingTransactionTxid.__table__.columns}
|
|
assert "unique" in sql.lower()
|
|
assert "ix_pending_transaction_txids_pending_transaction_id" in indexes
|
|
|
|
|
|
def test_existing_pending_transactions_are_backfilled_with_the_txids_they_remember(tmp_path):
|
|
"""B-77 (DAT-70): upgrading a database that already has rows records each one's
|
|
current txid with its raw transaction, and the txid a bump replaced with an empty
|
|
raw_hex — and the expression indexes survive the step."""
|
|
db_path = tmp_path / "old.db"
|
|
_alembic(db_path, "upgrade", "477bddae1a84")
|
|
with sqlite3.connect(db_path) as conn:
|
|
columns = (
|
|
"(kind, current_txid, fee_rate_sat_vb, raw_tx_hex, broadcast_at, last_broadcast_at, status, "
|
|
"replaced_by_txid, attempt_count, updated_at)"
|
|
)
|
|
stamp = "'2026-01-01 00:00:00'"
|
|
conn.execute(
|
|
f"INSERT INTO pending_transactions {columns} VALUES "
|
|
f"('bet', 'cc', 2, 'rawcc', {stamp}, {stamp}, 'pending', 'bb', 2, {stamp})"
|
|
)
|
|
conn.execute(
|
|
f"INSERT INTO pending_transactions {columns} VALUES "
|
|
f"('withdrawal', 'dd', 1, 'rawdd', {stamp}, {stamp}, 'building', NULL, 1, {stamp})"
|
|
)
|
|
_alembic(db_path, "upgrade", "head")
|
|
with sqlite3.connect(db_path) as conn:
|
|
rows = sorted(conn.execute("SELECT pending_transaction_id, txid, raw_hex FROM pending_transaction_txids"))
|
|
assert rows == [(1, "bb", ""), (1, "cc", "rawcc"), (2, "dd", "rawdd")]
|
|
assert _index_sql(db_path, "ix_rounds_single_active") is not None
|
|
assert _index_sql(db_path, "ix_users_username_lower") is not None
|
|
|
|
|
|
def test_failed_at_and_paused_reason_are_backfilled_for_existing_rows(tmp_path):
|
|
"""B-78 (DAT-36, DAT-69, DAT-72): rows that are already failed start their 14-day
|
|
watch from their last update, and an instance that is already paused was paused by its
|
|
operator; the expression indexes survive the add_column steps."""
|
|
db_path = tmp_path / "old.db"
|
|
_alembic(db_path, "upgrade", "a3f5c2d9b7e1")
|
|
with sqlite3.connect(db_path) as conn:
|
|
columns = (
|
|
"(kind, current_txid, fee_rate_sat_vb, raw_tx_hex, broadcast_at, last_broadcast_at, status, "
|
|
"attempt_count, updated_at)"
|
|
)
|
|
conn.execute(
|
|
f"INSERT INTO pending_transactions {columns} VALUES "
|
|
"('bet', 'aa', 1, 'raw', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'failed', 1, '2026-02-03 04:05:06')"
|
|
)
|
|
conn.execute(
|
|
f"INSERT INTO pending_transactions {columns} VALUES "
|
|
"('bet', 'bb', 1, 'raw', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'pending', 1, '2026-02-03 04:05:06')"
|
|
)
|
|
round_config = [r[1] for r in conn.execute("PRAGMA table_info(round_config)")]
|
|
assert "paused_reason" not in round_config
|
|
conn.execute(
|
|
"INSERT INTO round_config (fee_address, bet_amount_sats, round_duration_seconds, round_cooldown_seconds, "
|
|
"fee_rate_sat_vb, rbf_timeout_seconds, draw_animation_seconds, paused, updated_at) "
|
|
"VALUES ('', 1000000000, 600, 30, 1, 900, 20, 1, '2026-01-01 00:00:00')"
|
|
)
|
|
_alembic(db_path, "upgrade", "head")
|
|
with sqlite3.connect(db_path) as conn:
|
|
failed_at = dict(conn.execute("SELECT current_txid, failed_at FROM pending_transactions"))
|
|
reason = conn.execute("SELECT paused_reason FROM round_config").fetchone()[0]
|
|
assert failed_at == {"aa": "2026-02-03 04:05:06", "bb": None}
|
|
assert reason == "operator"
|
|
assert _index_sql(db_path, "ix_rounds_single_active") is not None
|
|
assert _index_sql(db_path, "ix_users_username_lower") is not None
|
|
|
|
|
|
def test_the_commit_reveal_columns_and_the_draw_position_index_exist_at_head(migrated_db):
|
|
"""B-73 (DAT-62 … DAT-66): the new columns, their defaults and the unique position index."""
|
|
with sqlite3.connect(migrated_db) as conn:
|
|
rounds = {r[1]: r for r in conn.execute("PRAGMA table_info(rounds)")}
|
|
for column in ("server_seed_enc", "server_seed", "server_seed_hash", "participants_hash", "draw_height"):
|
|
assert column in rounds and rounds[column][3] == 0 # nullable
|
|
assert rounds["draw_confirmations"][3] == 1 and rounds["draw_confirmations"][4] in ("3", "'3'")
|
|
participants = {r[1]: r for r in conn.execute("PRAGMA table_info(round_participants)")}
|
|
assert participants["bet_vout"][3] == 1 and participants["bet_vout"][4] in ("0", "'0'")
|
|
assert participants["draw_position"][3] == 0
|
|
config = {r[1]: r for r in conn.execute("PRAGMA table_info(round_config)")}
|
|
assert config["draw_confirmations"][4] in ("3", "'3'")
|
|
|
|
sql = _index_sql(migrated_db, "ux_round_participants_round_draw_position")
|
|
assert sql is not None and "unique" in sql and "(round_id, draw_position)" in sql
|
|
|
|
|
|
def test_two_participants_cannot_share_a_draw_position_but_null_positions_coexist(migrated_db):
|
|
with sqlite3.connect(migrated_db) as conn:
|
|
conn.execute("INSERT INTO rounds (status, opened_at) VALUES ('closed', '2026-01-01 00:00:00')")
|
|
round_id = conn.execute("SELECT max(id) FROM rounds").fetchone()[0]
|
|
columns = "(round_id, user_id, bet_amount_sats, bet_txid, broadcast_at, status, draw_position)"
|
|
|
|
def insert(user_id, position):
|
|
conn.execute(
|
|
f"INSERT INTO round_participants {columns} VALUES (?, ?, 1, 'tx', '2026-01-01 00:00:00', 'confirmed', ?)",
|
|
(round_id, user_id, position),
|
|
)
|
|
|
|
insert(1, None)
|
|
insert(2, None) # NULLs do not collide
|
|
insert(3, 0)
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
insert(4, 0)
|