Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
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>
This commit is contained in:
1 parent
82d078e2d6
commit
db24287ea5
42 files changed
+1715
-620
No files matched your search
@@ -0,0 +1,348 @@
|
||||
"""B-73 (RND-34, RND-37, API-45, DAT-64, SEC-56): the commit-reveal draw, end to end, and
|
||||
what the public can and cannot see of it.
|
||||
|
||||
A round is opened by the real service (so it carries its real encrypted secret), filled with
|
||||
confirmed bets, closed and drawn by the real scheduler against a listener that answers the
|
||||
quorum, and then read back through `GET /rounds/{id}/proof` — where the winner is recomputed
|
||||
here, from public data only and with the algorithm written out again independently of the
|
||||
code under test.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import AuditLog, Round, RoundConfig, RoundParticipant, User
|
||||
from app.rounds.scheduler import RoundScheduler
|
||||
from app.rounds.service import open_new_round_if_needed
|
||||
from app.wallet.hd import derive_pool_address
|
||||
from app.wallet.keystore import decrypt_round_seed
|
||||
from tests.unit.test_rounds_route import client # noqa: F401 (fixture)
|
||||
|
||||
_FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
|
||||
_BLOCK_HASH = "00000000" + "5a" * 28
|
||||
|
||||
|
||||
class _Listener:
|
||||
client = object()
|
||||
tip_height = 500
|
||||
tip_header_hex = "00"
|
||||
|
||||
def __init__(self, blocks: dict[int, str]):
|
||||
self.blocks = blocks
|
||||
self.asked: list[int] = []
|
||||
|
||||
async def agreed_block_hash(self, height: int):
|
||||
self.asked.append(height)
|
||||
return self.blocks.get(height), self.blocks.get(height)
|
||||
|
||||
|
||||
async def _noop_payout(round_id: int) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _chain_advances_while_waiting(monkeypatch, listener, to_height: int = 503) -> None:
|
||||
"""The draw is entered at tip 500 (draw height 501); its three confirmations arrive while
|
||||
it waits — modelled by making the scheduler's sleep move the chain on instead of sleeping."""
|
||||
import app.rounds.scheduler as scheduler_module
|
||||
|
||||
async def fake_sleep(_seconds):
|
||||
listener.tip_height = to_height
|
||||
|
||||
monkeypatch.setattr(scheduler_module.asyncio, "sleep", fake_sleep)
|
||||
|
||||
|
||||
async def _open_round_with_bets(session_factory, n: int) -> int:
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS, draw_confirmations=3))
|
||||
await session.commit()
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_.status = "closing"
|
||||
round_.closed_at = datetime.now(timezone.utc)
|
||||
for i in range(n):
|
||||
user = User(username=f"p{i}", password_hash="x", derivation_index=i, address=derive_pool_address(2000 + i))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
session.add(
|
||||
RoundParticipant(
|
||||
round_id=round_.id,
|
||||
user_id=user.id,
|
||||
bet_amount_sats=999_000_000,
|
||||
bet_txid=hashlib.sha256(f"bet{i}".encode()).hexdigest(),
|
||||
bet_vout=0,
|
||||
status="confirmed",
|
||||
broadcast_at=datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=n - i),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return round_.id
|
||||
|
||||
|
||||
async def _all_text_in_the_database(session_factory) -> list[str]:
|
||||
"""Every string stored in any table — columns and audit payloads alike."""
|
||||
from app.db import base as db_base
|
||||
|
||||
path = db_base.engine.url.database
|
||||
with sqlite3.connect(path) as conn:
|
||||
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
values: list[str] = []
|
||||
for table in tables:
|
||||
for row in conn.execute(f'SELECT * FROM "{table}"'):
|
||||
values.extend(str(v) for v in row if isinstance(v, (str, bytes)))
|
||||
return values
|
||||
|
||||
|
||||
def _independent_winner(proof: dict) -> tuple[int, str]:
|
||||
"""The verification of docs/ciclo-di-vita-del-round.md, steps 1-6, from the proof alone."""
|
||||
assert hashlib.sha256(bytes.fromhex(proof["server_seed"])).hexdigest() == proof["server_seed_hash"]
|
||||
participants = proof["participants"]
|
||||
assert [p["draw_position"] for p in participants] == list(range(len(participants)))
|
||||
assert [p["txid"] for p in participants] == sorted(p["txid"] for p in participants)
|
||||
lines = "".join(f"{p['txid']}:{p['vout']}:{p['address']}\n" for p in participants)
|
||||
assert hashlib.sha256(lines.encode("utf-8")).hexdigest() == proof["participants_hash"]
|
||||
material = (
|
||||
bytes.fromhex(proof["server_seed"])
|
||||
+ bytes.fromhex(proof["draw_block_hash"])
|
||||
+ bytes.fromhex(proof["participants_hash"])
|
||||
)
|
||||
seed = int.from_bytes(hashlib.sha256(material).digest(), "big")
|
||||
assert str(seed) == proof["seed"]
|
||||
index = seed % len(participants)
|
||||
return index, participants[index]["address"]
|
||||
|
||||
|
||||
async def test_a_finished_draw_can_be_recomputed_from_the_public_proof(client, caplog, monkeypatch):
|
||||
"""RND-17 / RND-37 / API-45: anyone can redo the calculation from the proof and get the
|
||||
winner the server stored. Also SEC-48: the secret never reaches the application log."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=5)
|
||||
listener = _Listener({501: _BLOCK_HASH})
|
||||
_chain_advances_while_waiting(monkeypatch, listener)
|
||||
scheduler = RoundScheduler(session_factory, listener)
|
||||
scheduler._trigger_payout = _noop_payout
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="app"):
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
assert listener.asked == [501] # tip 500 + 1, asked for exactly that height
|
||||
response = await ac.get(f"/rounds/{round_id}/proof")
|
||||
assert response.status_code == 200
|
||||
proof = response.json()
|
||||
assert proof["algorithm"] == "commit-reveal-v1"
|
||||
assert proof["status"] == "paying_out"
|
||||
assert proof["draw_height"] == 501 and proof["draw_confirmations"] == 3
|
||||
assert proof["draw_block_hash"] == _BLOCK_HASH
|
||||
assert len(proof["participants"]) == 5
|
||||
assert all(p["vout"] == 0 and p["status"] == "confirmed" for p in proof["participants"])
|
||||
|
||||
index, address = _independent_winner(proof)
|
||||
assert (proof["winner_index"], proof["winner_address"]) == (index, address)
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
winner = await session.get(User, round_.winner_user_id)
|
||||
assert winner.address == address
|
||||
assert proof["pool_address"] == round_.pool_address
|
||||
|
||||
application_log = "\n".join(r.getMessage() for r in caplog.records if r.name.startswith("app"))
|
||||
assert application_log # something was logged ...
|
||||
assert proof["server_seed"] not in application_log # ... and never the secret (SEC-48)
|
||||
|
||||
|
||||
async def test_the_secret_is_nowhere_in_the_database_or_the_api_before_the_reveal(client):
|
||||
"""B-73 / DAT-64 / SEC-56: from the opening to the moment the winner is stored, the
|
||||
plaintext secret is in no database column and no audit row, and no endpoint returns it —
|
||||
only its encrypted form and its hash are stored. Checked at every stage."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=3)
|
||||
async with session_factory() as session:
|
||||
seed = decrypt_round_seed((await session.get(Round, round_id)).server_seed_enc)
|
||||
|
||||
async def assert_hidden(stage: str) -> None:
|
||||
for value in await _all_text_in_the_database(session_factory):
|
||||
assert seed not in value, f"the plaintext secret is stored in the database ({stage})"
|
||||
for path in ("/rounds/current", f"/rounds/{round_id}/proof"):
|
||||
body = (await ac.get(path)).text
|
||||
assert seed not in body, f"{path} returns the secret ({stage})"
|
||||
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
assert proof["server_seed"] is None
|
||||
assert proof["server_seed_hash"] == hashlib.sha256(bytes.fromhex(seed)).hexdigest()
|
||||
|
||||
await assert_hidden("closing") # the commitment is public from the opening
|
||||
|
||||
# Entering `drawing` and waiting for the block: the draw's wait is interrupted before
|
||||
# the winner is stored.
|
||||
scheduler = RoundScheduler(session_factory, _Listener({}))
|
||||
|
||||
async def stop_before_the_draw(_round_id: int) -> None:
|
||||
return None
|
||||
|
||||
scheduler._complete_draw = stop_before_the_draw
|
||||
await scheduler._close_and_draw(round_id)
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(Round, round_id)).status == "drawing"
|
||||
await assert_hidden("drawing")
|
||||
|
||||
# Now the draw completes: the secret appears, and only now.
|
||||
listener = _Listener({501: _BLOCK_HASH})
|
||||
listener.tip_height = 503
|
||||
finishing = RoundScheduler(session_factory, listener)
|
||||
finishing._trigger_payout = _noop_payout
|
||||
await finishing._complete_draw(round_id)
|
||||
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
assert proof["server_seed"] == seed
|
||||
async with session_factory() as session:
|
||||
rows = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "winner_drawn"))).all()
|
||||
payload = json.loads(rows[0].payload_json)
|
||||
assert payload["server_seed"] == seed # AUD-24: revealed in the same transaction as the winner
|
||||
assert payload["draw_height"] == payload["block_height"] == 501
|
||||
assert re.fullmatch(r"[0-9a-f]{64}", payload["seed"])
|
||||
assert int(payload["seed"], 16) == int(proof["seed"])
|
||||
|
||||
|
||||
async def test_the_proof_of_a_round_in_drawing_has_the_committed_list_but_no_result(client):
|
||||
"""RND-37: from the entry in `drawing` the list, its fingerprint and the height are
|
||||
public; the block hash, the seed and the winner are not yet."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=3)
|
||||
scheduler = RoundScheduler(session_factory, _Listener({}))
|
||||
|
||||
async def stop(_round_id: int) -> None:
|
||||
return None
|
||||
|
||||
scheduler._complete_draw = stop
|
||||
await scheduler._close_and_draw(round_id)
|
||||
|
||||
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
assert proof["status"] == "drawing"
|
||||
assert proof["draw_height"] == 501
|
||||
assert len(proof["participants"]) == 3 and proof["participants_hash"]
|
||||
for key in ("server_seed", "draw_block_hash", "seed", "winner_index", "winner_address", "payout_txid"):
|
||||
assert proof[key] is None
|
||||
|
||||
|
||||
async def test_the_proof_of_a_round_still_open_has_only_the_commitment(client):
|
||||
ac, session_factory = client
|
||||
async with session_factory() as session:
|
||||
session.add(RoundConfig(fee_address=_FEE_ADDRESS))
|
||||
await session.commit()
|
||||
round_ = await open_new_round_if_needed(session)
|
||||
await session.commit()
|
||||
round_id = round_.id
|
||||
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
assert proof["status"] == "open" and re.fullmatch(r"[0-9a-f]{64}", proof["server_seed_hash"])
|
||||
assert proof["participants"] == [] and proof["participants_hash"] is None and proof["draw_height"] is None
|
||||
assert proof["server_seed"] is None and proof["pool_address"]
|
||||
|
||||
|
||||
async def test_the_proof_needs_no_authentication_and_404s_for_an_unknown_round(client):
|
||||
ac, _ = client
|
||||
response = await ac.get("/rounds/999/proof")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "round_not_found"
|
||||
assert (await ac.get("/rounds/not-a-number/proof")).status_code == 422
|
||||
|
||||
|
||||
async def test_a_round_from_before_the_commitment_has_an_incomplete_block_hash_proof(client):
|
||||
"""API-45: a round without a commitment is labelled `block-hash-v0` and carries no list."""
|
||||
ac, session_factory = client
|
||||
async with session_factory() as session:
|
||||
session.add(Round(id=7, status="closed", draw_block_hash="ab" * 32, seed_int="12345"))
|
||||
await session.commit()
|
||||
proof = (await ac.get("/rounds/7/proof")).json()
|
||||
assert proof["algorithm"] == "block-hash-v0"
|
||||
assert proof["server_seed_hash"] is None and proof["participants"] == [] and proof["winner_address"] is None
|
||||
assert proof["draw_block_hash"] == "ab" * 32 and proof["seed"] == "12345"
|
||||
|
||||
|
||||
async def test_current_round_publishes_the_commitment_and_what_is_fixed_for_the_draw(client):
|
||||
"""API-25: server_seed_hash from the opening; participants_hash and draw_height once in
|
||||
drawing; draw_confirmations always — and never the secret."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=2)
|
||||
current = (await ac.get("/rounds/current")).json()
|
||||
assert re.fullmatch(r"[0-9a-f]{64}", current["server_seed_hash"])
|
||||
assert current["draw_confirmations"] == 3
|
||||
assert current["participants_hash"] is None and current["draw_height"] is None
|
||||
assert "server_seed" not in current
|
||||
|
||||
scheduler = RoundScheduler(session_factory, _Listener({}))
|
||||
|
||||
async def stop(_round_id: int) -> None:
|
||||
return None
|
||||
|
||||
scheduler._complete_draw = stop
|
||||
await scheduler._close_and_draw(round_id)
|
||||
current = (await ac.get("/rounds/current")).json()
|
||||
assert current["draw_height"] == 501 and re.fullmatch(r"[0-9a-f]{64}", current["participants_hash"])
|
||||
|
||||
|
||||
async def test_a_drawing_round_is_completed_by_a_second_pass_with_the_same_winner(client, monkeypatch):
|
||||
"""RND-36: everything the draw needs is in the database, so running the completion again
|
||||
— after an exception, in a new scheduler — gives the same winner, and a round that was
|
||||
already drawn is not drawn again."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=6)
|
||||
|
||||
class _Failing(_Listener):
|
||||
async def agreed_block_hash(self, height):
|
||||
raise RuntimeError("database is locked") # e.g. while writing an audit row
|
||||
|
||||
failing = _Failing({})
|
||||
_chain_advances_while_waiting(monkeypatch, failing)
|
||||
first = RoundScheduler(session_factory, failing)
|
||||
first._trigger_payout = _noop_payout
|
||||
try:
|
||||
await first._close_and_draw(round_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
async with session_factory() as session:
|
||||
assert (await session.get(Round, round_id)).status == "drawing"
|
||||
|
||||
ok = _Listener({501: _BLOCK_HASH})
|
||||
ok.tip_height = 503
|
||||
second = RoundScheduler(session_factory, ok)
|
||||
second._trigger_payout = _noop_payout
|
||||
await second._complete_draw(round_id)
|
||||
proof = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
_, address = _independent_winner(proof)
|
||||
assert proof["winner_address"] == address
|
||||
|
||||
other = _Listener({501: "ff" * 32}) # a different block is offered
|
||||
other.tip_height = 503
|
||||
third = RoundScheduler(session_factory, other)
|
||||
third._trigger_payout = _noop_payout
|
||||
await third._complete_draw(round_id)
|
||||
again = (await ac.get(f"/rounds/{round_id}/proof")).json()
|
||||
assert again["winner_address"] == proof["winner_address"] and again["draw_block_hash"] == _BLOCK_HASH
|
||||
|
||||
|
||||
async def test_a_different_block_gives_a_different_draw_but_never_a_different_list(client):
|
||||
"""Why the order is public: the same participants with a different block at the draw
|
||||
height can produce a different winner, while the list itself is fixed beforehand."""
|
||||
ac, session_factory = client
|
||||
round_id = await _open_round_with_bets(session_factory, n=8)
|
||||
scheduler = RoundScheduler(session_factory, _Listener({}))
|
||||
|
||||
async def stop(_round_id: int) -> None:
|
||||
return None
|
||||
|
||||
scheduler._complete_draw = stop
|
||||
await scheduler._close_and_draw(round_id)
|
||||
list_hash = (await ac.get(f"/rounds/{round_id}/proof")).json()["participants_hash"]
|
||||
|
||||
winners = set()
|
||||
for block in ("01" * 32, "02" * 32, "03" * 32, "04" * 32, "05" * 32, "06" * 32):
|
||||
async with session_factory() as session:
|
||||
round_ = await session.get(Round, round_id)
|
||||
from app.rounds.draw import draw_index, draw_seed
|
||||
|
||||
seed = decrypt_round_seed(round_.server_seed_enc)
|
||||
winners.add(draw_index(draw_seed(seed, block, round_.participants_hash), 8))
|
||||
assert len(winners) > 1 # the block matters ...
|
||||
assert (await ac.get(f"/rounds/{round_id}/proof")).json()["participants_hash"] == list_hash # ... the list does not move
|
||||
Reference in new issue
Block a user