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:
davideandClaude Sonnet 5.5 committed 2026-10-14 16:29:09 +02:00
1 parent 82d078e2d6
commit db24287ea5
42 files changed
+1715 -620

No files matched your search

+54 -9
View File
@@ -1,4 +1,5 @@
import hashlib
import secrets
# Byte offsets of a standard 80-byte block header: version(4) + prev_block(32) +
# merkle_root(32) + timestamp(4) + bits(4) + nonce(4).
@@ -68,8 +69,8 @@ def header_meets_its_own_target(header_hex: str) -> bool:
a real but implausibly low self-chosen difficulty: a server could still declare
an easy target and grind it out with modest hardware. That residual risk is why
the draw additionally requires the winning block's header to be corroborated by
the *other* configured servers before using it as the seed (see
ElectrumListener.corroborate_header and rounds/scheduler.py:_wait_for_next_block)
the configured servers at a fixed height before using it as the seed (see
ElectrumListener.agreed_block_hash and rounds/scheduler.py:_wait_for_draw_block)
rather than relying on this check alone.
"""
header_bytes = bytes.fromhex(header_hex)
@@ -88,12 +89,56 @@ def header_meets_its_own_target(header_hex: str) -> bool:
return hash_int <= target
def draw_winner(participants: list[str], block_hash_hex: str) -> str:
"""v1 draw algorithm (docs/ciclo-di-vita-del-round.md): seed = block hash as an
integer, index = seed mod participant_count, winner = participants[index]. Anyone
can recompute and verify it from public data. Deliberately simple/replaceable."""
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def new_server_seed() -> str:
"""B-73 (RND-34, DAT-64): a round's secret — 32 random bytes from the OS CSPRNG, as
64 lowercase hex characters."""
return secrets.token_bytes(32).hex()
def server_seed_commitment(server_seed_hex: str) -> str:
"""`server_seed_hash`: sha256 of the secret's 32 bytes, published from the opening of
the round so the operator cannot change the secret afterwards (RND-34)."""
return sha256_hex(bytes.fromhex(server_seed_hex))
def participants_hash(rows: list[tuple[str, int, str]]) -> str:
"""The fingerprint of the ordered draw list (RND-14): sha256 of one `txid:vout:address`
line per participant, in `draw_position` order, each line ending in `\n`, UTF-8. `txid`
is the bet transaction's, lowercase hex as explorers show it; `vout` is the output paying
the round's pool address, in decimal; `address` is the participant's own."""
text = "".join(f"{txid}:{vout}:{address}\n" for txid, vout, address in rows)
return sha256_hex(text.encode("utf-8"))
def draw_seed(server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> int:
"""RND-17: sha256 of the secret (32 bytes), the hash of the block at `draw_height` (32
bytes, in the order explorers show it) and `participants_hash` (32 bytes), read as an
unsigned big-endian integer. Nobody who lacks the secret can predict it, and the
operator, who knows the secret, cannot know the block when committing to the list."""
digest = hashlib.sha256(
bytes.fromhex(server_seed_hex) + bytes.fromhex(block_hash_hex) + bytes.fromhex(participants_hash_hex)
).digest()
return int.from_bytes(digest, "big")
def draw_index(seed: int, participant_count: int) -> int:
"""The winner's `draw_position`: seed modulo the number of participants. Every
participant holds exactly one position, so odds are equal whatever the amount."""
if participant_count <= 0:
raise ValueError("no participants to draw from")
return seed % participant_count
def draw_winner(participants: list[str], server_seed_hex: str, block_hash_hex: str, participants_hash_hex: str) -> str:
"""The v1 draw algorithm (docs/ciclo-di-vita-del-round.md RND-17), recomputable by anyone
from the public proof (GET /rounds/{id}/proof): `participants` are the entries in
`draw_position` order, the winner is the one at `draw_index(draw_seed(...), n)`. Deliberately
simple and replaceable."""
if not participants:
raise ValueError("no participants to draw from")
seed = int(block_hash_hex, 16)
index = seed % len(participants)
return participants[index]
seed = draw_seed(server_seed_hex, block_hash_hex, participants_hash_hex)
return participants[draw_index(seed, len(participants))]
+202 -116
View File
@@ -14,12 +14,19 @@ from app.db.models import AuditLog, PendingTransaction, Round, RoundParticipant,
from app.electrum.listener import ElectrumListener
from app.electrum.scripthash import address_to_scripthash
from app.rounds.config import get_round_config
from app.rounds.draw import draw_winner, header_hex_to_block_hash
from app.rounds.draw import (
draw_index,
draw_seed,
new_server_seed,
participants_hash,
server_seed_commitment,
)
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed, round_deadline, winner_share
from app.tx.outcome import BroadcastOutcome, broadcast_with_verdict
from app.tx.versions import record_version
from app.wallet.hd import derive_round_pool_key
from app.wallet.keystore import decrypt_round_seed, encrypt_round_seed
from app.wallet.plm_network import PLM_MAINNET
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_payout_transaction
@@ -128,6 +135,10 @@ class RoundScheduler:
await self._close_and_draw(round_id)
async def _close_and_draw(self, round_id: int) -> None:
"""Close a `closing` round once nothing is in flight: either close it (no confirmed
participant) or commit to the draw — fix the participant list, its fingerprint and the
draw height, all in one transaction, and enter `drawing` (RND-13, RND-14, RND-33,
DAT-65) — then run the draw itself (`_complete_draw`)."""
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
@@ -153,48 +164,140 @@ class RoundScheduler:
)
return
# B-73 (RND-14, DAT-66): the draw order is the bet txid, ascending — a value the
# public can read off the chain, with no ties (two bets never share a txid). The
# server-only `broadcast_at` it replaces could not be checked by anyone.
participants = (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.status == "confirmed")
.order_by(RoundParticipant.broadcast_at)
.order_by(RoundParticipant.bet_txid)
)
).all()
if not participants:
round_.status = "closed"
# DAT-64: nothing was drawn, so the secret has nothing left to protect.
if round_.server_seed_enc and not round_.server_seed:
round_.server_seed = decrypt_round_seed(round_.server_seed_enc)
await write_audit_log(session, "round_closed", {"participants": 0}, round_id=round_id)
await session.commit()
broadcaster.publish()
logger.info("round %s closed with no participants", round_id)
return
pool_amount = sum(p.bet_amount_sats for p in participants)
addresses: list[str] = []
user_by_address: dict[str, int] = {}
for p in participants:
user = await session.get(User, p.user_id)
addresses.append(user.address)
user_by_address[user.address] = user.id
# B-73 (RND-19, RND-33): draw_height is "the height we knew at closing, plus one", so
# an unknown height (0) cannot be a base — the round stays "closing" and this is
# tried again at the next step, with one audit row per round.
tip_at_close = self._listener.tip_height
if tip_at_close <= 0:
already_written = await session.scalar(
select(func.count())
.select_from(AuditLog)
.where(AuditLog.event_type == "draw_baseline_tip_unknown", AuditLog.round_id == round_id)
)
if not already_written:
logger.warning("round %s: chain tip unknown at closing time, not entering drawing yet", round_id)
await write_audit_log(session, "draw_baseline_tip_unknown", {}, round_id=round_id)
await session.commit()
return
users = {
user.id: user
for user in (
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
).all()
}
if not round_.server_seed_enc:
# A round opened before B-73 has no commitment. Give it a secret now: it still
# cannot be known to anyone who could steer the block, because the list and the
# height are fixed below before that block exists — only the early publication
# of the commitment is missing for this one round.
seed = new_server_seed()
round_.server_seed_hash = server_seed_commitment(seed)
round_.server_seed_enc = encrypt_round_seed(seed)
for position, participant in enumerate(participants):
participant.draw_position = position
round_.participants_hash = participants_hash(
[(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
)
round_.draw_height = tip_at_close + 1
round_.pool_amount_sats = sum(p.bet_amount_sats for p in participants)
round_.status = "drawing"
drawing_started_at = datetime.now(timezone.utc)
round_.drawing_started_at = drawing_started_at
round_.drawing_started_at = datetime.now(timezone.utc)
await write_audit_log(
session,
"draw_committed",
{
"participants_hash": round_.participants_hash,
"draw_height": round_.draw_height,
"participants": len(participants),
"server_seed_hash": round_.server_seed_hash,
},
round_id=round_id,
)
await session.commit()
broadcaster.publish()
tip_at_close = self._listener.tip_height
block_height, block_hash = await self._wait_for_next_block(round_id, tip_at_close, drawing_started_at)
winner_address = draw_winner(addresses, block_hash)
await self._complete_draw(round_id)
async def _complete_draw(self, round_id: int) -> None:
"""The draw of a round in `drawing`, from nothing but what the database holds (RND-36,
DAT-65): wait for the block at `draw_height` to be confirmed and agreed on by the
quorum, compute the winner, store it and reveal the secret in one transaction, then
start the payout. Idempotent — a round no longer in `drawing`, or with a winner
already stored, is left alone."""
async with self._session_factory() as session:
round_ = await session.get(Round, round_id)
if round_ is None or round_.status != "drawing" or round_.winner_user_id is not None:
return
draw_height = round_.draw_height
draw_confirmations = round_.draw_confirmations
committed_hash = round_.participants_hash
server_seed_enc = round_.server_seed_enc
server_seed_hash = round_.server_seed_hash
waiting_since = round_.drawing_started_at.replace(tzinfo=timezone.utc)
block_hash = await self._wait_for_draw_block(round_id, draw_height, draw_confirmations, waiting_since)
server_seed = decrypt_round_seed(server_seed_enc)
if server_seed_commitment(server_seed) != server_seed_hash:
raise RuntimeError(f"round {round_id}: the stored secret does not match its published commitment")
async with self._session_factory() as session:
await begin_immediate(session) # B-88
round_ = await session.get(Round, round_id)
round_.draw_block_height = block_height
if round_.status != "drawing" or round_.winner_user_id is not None:
return # drawn meanwhile (a second pass over the same round)
participants = (
await session.scalars(
select(RoundParticipant)
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
.order_by(RoundParticipant.draw_position)
)
).all()
users = {
user.id: user
for user in (
await session.scalars(select(User).where(User.id.in_({p.user_id for p in participants})))
).all()
}
ordered_rows = [(p.bet_txid, p.bet_vout, users[p.user_id].address) for p in participants]
if participants_hash(ordered_rows) != committed_hash:
raise RuntimeError(f"round {round_id}: the draw list no longer matches its published fingerprint")
seed = draw_seed(server_seed, block_hash, committed_hash)
winner = participants[draw_index(seed, len(participants))]
winner_address = users[winner.user_id].address
pool_amount = sum(p.bet_amount_sats for p in participants if p.status == "confirmed")
round_.draw_block_height = draw_height
round_.draw_block_hash = block_hash
round_.seed_int = str(int(block_hash, 16))
round_.winner_user_id = user_by_address[winner_address]
round_.seed_int = str(seed)
round_.winner_user_id = winner.user_id
round_.pool_amount_sats = pool_amount
# The reveal (RND-18, DAT-64): the plaintext secret becomes visible in the same
# transaction that stores the winner, never before.
round_.server_seed = server_seed
round_.status = "paying_out"
await write_audit_log(
session,
@@ -202,11 +305,15 @@ class RoundScheduler:
{
"winner_address": winner_address,
"pool_amount_sats": pool_amount,
"block_height": block_height,
"block_height": draw_height,
"block_hash": block_hash,
"participants": len(addresses),
"participants": len(participants),
"draw_height": draw_height,
"participants_hash": committed_hash,
"server_seed": server_seed,
"seed": f"{seed:064x}",
},
user_id=user_by_address[winner_address],
user_id=winner.user_id,
round_id=round_id,
)
await session.commit()
@@ -215,115 +322,94 @@ class RoundScheduler:
logger.info("round %s: winner=%s pool=%s", round_id, winner_address, pool_amount)
await self._trigger_payout(round_id)
async def _wait_for_next_block(
self, round_id: int, tip_at_close: int, waiting_since: datetime
) -> tuple[int, str]:
"""Waits for a block after tip_at_close and, before handing it back as the
draw's entropy source, requires it to be corroborated by the other
configured Electrum servers (B-28) — our own active connection is exactly
the thing a hostile server or a MITM would control, so its header alone is
not enough to seed a payout. A candidate that fails corroboration is never
used: this keeps waiting for a further block and tries corroborating that
one instead, logging why every time so a stuck draw is visible in
/admin's audit log rather than a silent, unexplained wait.
async def _wait_for_draw_block(
self, round_id: int, draw_height: int, confirmations: int, waiting_since: datetime
) -> str:
"""The hash of the block at `draw_height`, once it is safe to seed the draw with it
(RND-15, RND-16, RND-20; B-73): the chain must have `confirmations` blocks from that
height (a tip of at least `draw_height + confirmations - 1`), and then the quorum of
servers must agree on the header *at that exact height* — never on the tip, never on
a later block. If they do not agree the same height is asked again at the next step
and `draw_header_corroboration_failed` is written once per distinct hash the active
server reported, so a disagreement that lasts does not write a row every 5 seconds.
Whoever produces or relays blocks therefore cannot choose which block seeds the draw:
withholding or delaying one only delays the draw.
This wait has no timeout — it can't, since the draw's entropy genuinely
depends on a future block. B-36: what it lacked was *visibility*, so a
connection that stopped advancing the tip left the round silently frozen
in "drawing" with nothing in the logs or /admin to explain why. Progress
is now logged periodically, and past _DRAW_STALL_THRESHOLD_SECONDS a
draw_stalled audit entry is written (and re-written every threshold
interval for as long as the stall continues) so the wait shows up next
to the draw_header_corroboration_failed entries above.
B-63: `tip_at_close` of 0 means the tip was *unknown* when the round closed,
not that the chain was at height zero — and "the first block we hear about"
is then not necessarily a block mined after the close. Rather than seed the
draw from a hash that may already have been public while bets were open, the
first height we do learn becomes the baseline and this waits for a block
strictly after it. Since the Electrum listener now only publishes its client
once a header has been applied, and _tick won't run without one, this should
be unreachable — it stays as the local statement of what the draw actually
requires, since nothing else in this function would notice if that stopped
holding.
The wait has no timeout — the entropy genuinely depends on a future block — but it is
observable (B-36): progress is logged every _DRAW_PROGRESS_LOG_INTERVAL_SECONDS, and a
`draw_stalled` row is written when for _DRAW_STALL_THRESHOLD_SECONDS the chain has not
advanced while the round waits for confirmations, or the quorum has been failing to
agree — and repeated every threshold for as long as that lasts. The clock restarts
whenever the tip advances during the confirmation wait, so a chain that is merely
producing its three blocks at the normal pace never counts as stalled.
"""
next_progress_log_at = waiting_since + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
next_stall_audit_at = waiting_since + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
if tip_at_close <= 0:
tip_at_close = await self._adopt_baseline_tip(round_id)
required_tip = draw_height + confirmations - 1
now = datetime.now(timezone.utc)
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
stall_since = now
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
last_tip = self._listener.tip_height
agreeing = False # True once the confirmations are there and only the quorum is awaited
reported_hashes: set[str | None] = set()
while True:
while self._listener.tip_height <= tip_at_close or not self._listener.tip_header_hex:
now = datetime.now(timezone.utc)
if now >= next_progress_log_at:
logger.info(
"round %s: still waiting for a block past height %s (%.0fs since drawing started)",
now = datetime.now(timezone.utc)
tip = self._listener.tip_height
if tip >= required_tip:
if not agreeing:
agreeing = True
stall_since = now
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
block_hash, reported = await self._listener.agreed_block_hash(draw_height)
if block_hash is not None:
return block_hash
if reported not in reported_hashes:
reported_hashes.add(reported)
logger.error(
"round %s: block %s header (active server: %s) is not agreed by the other Electrum "
"servers; asking again for the same height",
round_id,
tip_at_close,
(now - waiting_since).total_seconds(),
draw_height,
reported,
)
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
if now >= next_stall_audit_at:
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_stalled",
{
"tip_at_close": tip_at_close,
"current_tip_height": self._listener.tip_height,
"elapsed_seconds": int((now - waiting_since).total_seconds()),
},
"draw_header_corroboration_failed",
{"height": draw_height, "reported_hash": reported},
round_id=round_id,
)
await session.commit()
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
height = self._listener.tip_height
block_hash = header_hex_to_block_hash(self._listener.tip_header_hex)
if await self._listener.corroborate_header(height, block_hash):
return height, block_hash
logger.error(
"round %s: block %s header %s could not be corroborated by other Electrum servers; "
"waiting for a further block",
round_id,
height,
block_hash,
)
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_header_corroboration_failed",
{"height": height, "reported_hash": block_hash},
round_id=round_id,
)
await session.commit()
tip_at_close = height
elif tip != last_tip:
last_tip = tip
stall_since = now
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
async def _adopt_baseline_tip(self, round_id: int) -> int:
"""B-63: the height the draw must find a *later* block than, for the case
where the tip wasn't known at closing time. Waits for a header to arrive and
takes that height as the baseline — the block it describes may predate the
close, which is exactly why it is used as the floor rather than as the seed —
and records why, since a draw that waits one extra block should be explainable
from /admin rather than looking like a stall.
"""
while not self._listener.tip_header_hex or self._listener.tip_height <= 0:
if now >= next_progress_log_at:
logger.info(
"round %s: still waiting for block %s (%s confirmation(s) needed, chain at %s, %.0fs since drawing started)",
round_id,
draw_height,
confirmations,
tip,
(now - waiting_since).total_seconds(),
)
next_progress_log_at = now + timedelta(seconds=_DRAW_PROGRESS_LOG_INTERVAL_SECONDS)
if now >= next_stall_audit_at:
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_stalled",
{
"draw_height": draw_height,
"current_tip_height": tip,
"elapsed_seconds": int((now - stall_since).total_seconds()),
},
round_id=round_id,
)
await session.commit()
next_stall_audit_at = now + timedelta(seconds=_DRAW_STALL_THRESHOLD_SECONDS)
await asyncio.sleep(_TICK_INTERVAL_SECONDS)
height = self._listener.tip_height
logger.warning(
"round %s: chain tip was unknown at closing time; using height %s as the draw baseline "
"and waiting for a further block",
round_id,
height,
)
async with self._session_factory() as session:
await write_audit_log(
session,
"draw_baseline_tip_unknown",
{"baseline_height": height},
round_id=round_id,
)
await session.commit()
return height
async def _retry_payout_if_due(self, round_id: int) -> None:
"""B-26: whether a "paying_out" round is due for another payout attempt.
+16
View File
@@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Round, RoundConfig
from app.rounds.config import get_round_config
from app.rounds.events import broadcaster
from app.rounds.draw import new_server_seed, server_seed_commitment
from app.wallet.hd import derive_pool_address
from app.wallet.keystore import encrypt_round_seed
logger = logging.getLogger(__name__)
@@ -24,6 +26,16 @@ _OPEN_ROUND_ATTEMPTS = 3
# advertised jackpot (rounds.py) and the actual payout (scheduler.py) can't diverge.
def _new_round_secret() -> dict:
"""B-73 (RND-34): a fresh secret for a round about to open, as the columns that hold it —
its published commitment and its encrypted form, never the plaintext."""
server_seed = new_server_seed()
return {
"server_seed_hash": server_seed_commitment(server_seed),
"server_seed_enc": encrypt_round_seed(server_seed),
}
def winner_share(pool_amount_sats: int) -> int:
return pool_amount_sats * 70 // 100
@@ -129,6 +141,10 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None:
status="open",
duration_seconds=config.round_duration_seconds,
cooldown_seconds=config.round_cooldown_seconds,
draw_confirmations=config.draw_confirmations, # B-73 (DAT-61)
# B-73 (RND-34, DAT-64): the commitment, then the secret itself encrypted — the
# plaintext column stays NULL until the reveal.
**_new_round_secret(),
)
session.add(round_)
try: