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

+7
View File
@@ -45,6 +45,7 @@ _CONFIG_FIELDS = (
"fee_rate_sat_vb",
"rbf_timeout_seconds",
"draw_animation_seconds",
"draw_confirmations", # B-73 (DAT-62)
)
@@ -56,6 +57,7 @@ class RoundConfigResponse(BaseModel):
fee_rate_sat_vb: int
rbf_timeout_seconds: int
draw_animation_seconds: int
draw_confirmations: int # B-73 (DAT-62)
paused: bool
paused_reason: str | None = None # B-78 (DAT-69): why, or None when not paused
@@ -77,6 +79,7 @@ class RoundConfigUpdate(BaseModel):
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=MAX_FEE_RATE_SAT_VB)
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
draw_confirmations: int | None = Field(default=None, ge=1, le=20) # B-73 (DAT-62)
@field_validator("fee_address")
@classmethod
@@ -264,6 +267,8 @@ class AdminRoundResponse(BaseModel):
fee_amount_sats: int | None
payout_txid: str | None
pool_address: str | None # B-80
server_seed_hash: str | None # B-73 (API-36): the commitment only — never the secret
draw_height: int | None # B-73
@router.get("/rounds", response_model=list[AdminRoundResponse], dependencies=[Depends(require_admin)])
@@ -292,6 +297,8 @@ async def list_rounds(
fee_amount_sats=r.fee_amount_sats,
payout_txid=r.payout_txid,
pool_address=r.pool_address or None,
server_seed_hash=r.server_seed_hash,
draw_height=r.draw_height,
)
for r in rounds
]
+100 -1
View File
@@ -9,9 +9,10 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip
from app.api.errors import http_error
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_optional_user
from app.db.models import RoundParticipant, User
from app.db.models import Round, RoundParticipant, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
@@ -118,6 +119,13 @@ class CurrentRoundResponse(BaseModel):
# waiting for a block" rather than a countdown implying a bounded wait — this
# phase has no timeout, only draw_animation_seconds' cosmetic minimum.
draw_waiting_since: str | None = None
# B-73 (RND-34, RND-14): the commitment published from the opening, and what is fixed
# on entering "drawing". The secret itself is never in this response (API-45 has it, once
# revealed).
server_seed_hash: str | None = None
participants_hash: str | None = None
draw_height: int | None = None
draw_confirmations: int | None = None
chain_tip_height: int | None = None
lottery_paused: bool = False
paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused
@@ -225,9 +233,100 @@ async def current_round(
draw_block_height=round_.draw_block_height,
draw_block_hash=round_.draw_block_hash,
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
server_seed_hash=round_.server_seed_hash,
participants_hash=round_.participants_hash,
draw_height=round_.draw_height,
draw_confirmations=round_.draw_confirmations,
chain_tip_height=chain_tip_height,
lottery_paused=config.paused,
paused_reason=config.paused_reason,
lottery_configured=rounds_can_open(config),
user_played=user_played,
)
class ProofParticipant(BaseModel):
draw_position: int
txid: str
vout: int
address: str
amount_sats: int
status: str
class RoundProofResponse(BaseModel):
round_id: int
status: str
algorithm: str
pool_address: str | None
server_seed_hash: str | None
server_seed: str | None
draw_height: int | None
draw_confirmations: int
participants: list[ProofParticipant]
participants_hash: str | None
draw_block_hash: str | None
seed: str | None
winner_index: int | None
winner_address: str | None
payout_txid: str | None
@router.get("/{round_id}/proof", response_model=RoundProofResponse)
async def round_proof(round_id: int, session: AsyncSession = Depends(get_session)) -> RoundProofResponse:
"""B-73 (RND-37, API-45): everything needed to recompute a round's winner without
trusting the server — public, unauthenticated, for a round in any state. A field that
does not exist yet is null (or an empty list).
`server_seed` is the plaintext column, which is written only by the reveal (the same
transaction that stores the winner, or the close of a round without participants), so
there is no state of the round in which this endpoint can return it early.
"""
round_ = await session.get(Round, round_id)
if round_ is None:
raise http_error(404, "round_not_found", "round not found")
rows = (
await session.execute(
select(RoundParticipant, User.address)
.join(User, User.id == RoundParticipant.user_id)
.where(RoundParticipant.round_id == round_id, RoundParticipant.draw_position.is_not(None))
.order_by(RoundParticipant.draw_position)
)
).all()
participants = [
ProofParticipant(
draw_position=p.draw_position,
txid=p.bet_txid,
vout=p.bet_vout,
address=address,
amount_sats=p.bet_amount_sats,
status=p.status,
)
for p, address in rows
]
seed_decimal = round_.seed_int
winner_index = None
winner_address = None
if seed_decimal is not None and participants:
winner_index = int(seed_decimal) % len(participants)
winner_address = participants[winner_index].address
return RoundProofResponse(
round_id=round_.id,
status=round_.status,
algorithm="commit-reveal-v1" if round_.server_seed_hash else "block-hash-v0",
pool_address=round_.pool_address or None,
server_seed_hash=round_.server_seed_hash,
server_seed=round_.server_seed,
draw_height=round_.draw_height,
draw_confirmations=round_.draw_confirmations,
participants=participants,
participants_hash=round_.participants_hash,
draw_block_hash=round_.draw_block_hash,
seed=seed_decimal,
winner_index=winner_index,
winner_address=winner_address,
payout_txid=round_.payout_txid,
)
+2
View File
@@ -20,6 +20,7 @@ from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_user_key
from app.wallet.psbt_builder import (
MAX_PARTICIPANTS_PER_ROUND,
RECIPIENT_VOUT,
BuiltTransaction,
InsufficientFundsError,
Utxo,
@@ -143,6 +144,7 @@ async def place_bet(
user_id=user.id,
bet_amount_sats=built.recipient_sats,
bet_txid=built.txid,
bet_vout=RECIPIENT_VOUT, # B-73 (DAT-63)
broadcast_at=broadcast_at,
status="building",
)
+34 -2
View File
@@ -111,20 +111,48 @@ class Round(Base):
winner_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
fee_amount_sats: Mapped[int | None] = mapped_column(BigInteger, default=None)
payout_txid: Mapped[str | None] = mapped_column(String(64), default=None)
# B-73 (DAT-61, DAT-64, DAT-65): the commit-reveal draw. `server_seed_enc` is the round's
# 32-byte secret Fernet-encrypted with the master-xprv key, written at open together with
# `server_seed_hash` (sha256 of the secret, published from then on); the plaintext
# `server_seed` stays NULL until the reveal, in the transaction that stores the winner (or
# at the close of a round without participants), so a copy of the database alone never
# tells who will win. `participants_hash`, `draw_height` and each participant's
# `draw_position` are written when the round enters "drawing" and never change — what a
# resumed draw (B-81) reads instead of recomputing. `draw_confirmations` is the number of
# blocks the chain must have from `draw_height` before its hash is read, copied from the
# config at open.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
server_seed_enc: Mapped[str | None] = mapped_column(Text, default=None)
server_seed: Mapped[str | None] = mapped_column(String(64), default=None)
server_seed_hash: Mapped[str | None] = mapped_column(String(64), default=None)
participants_hash: Mapped[str | None] = mapped_column(String(64), default=None)
draw_height: Mapped[int | None] = mapped_column(default=None)
class RoundParticipant(Base):
__tablename__ = "round_participants"
__table_args__ = (UniqueConstraint("round_id", "user_id"),)
__table_args__ = (
UniqueConstraint("round_id", "user_id"),
# B-73 (DAT-66): one draw position per participant; NULLs (not yet in an ordered
# list) do not collide.
Index("ux_round_participants_round_draw_position", "round_id", "draw_position", unique=True),
)
id: Mapped[int] = mapped_column(primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
bet_amount_sats: Mapped[int] = mapped_column(BigInteger)
bet_txid: Mapped[str] = mapped_column(String(64))
# Ordering / tie-break field per spec: broadcast time, not confirmation time.
# B-73 (DAT-63): which output of the bet transaction pays the round's pool address —
# together with bet_txid the outpoint the draw list publishes and the payout spends.
bet_vout: Mapped[int] = mapped_column(default=0, server_default="0")
# Informational only since B-73 (DAT-25): the draw order is by bet_txid, which anyone
# can check, not by this server-side timestamp.
broadcast_at: Mapped[datetime] = mapped_column(default=utcnow)
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
# B-73 (DAT-66): 0-based position in the ordered draw list, written once on entering
# "drawing" for every confirmed participant.
draw_position: Mapped[int | None] = mapped_column(default=None)
status: Mapped[str] = mapped_column(String(16), default="broadcast")
@@ -149,6 +177,10 @@ class RoundConfig(Base):
# NOT gate the actual draw, which still waits for a real confirmed block for
# its entropy (rounds/scheduler.py) — that can take longer than this value.
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
# B-73 (DAT-62): blocks the chain must have from a round's draw_height before the hash
# of that block is read; copied onto each round when it opens (DAT-61), so an edit
# applies from the next round.
draw_confirmations: Mapped[int] = mapped_column(default=3, server_default="3")
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
# Maintenance switch: when true, the round currently in progress still runs to
+42 -25
View File
@@ -542,8 +542,8 @@ class ElectrumListener:
agrees: Callable[[object], bool],
description: str,
) -> bool:
"""Shared quorum logic behind corroborate_header (B-28) and
corroborate_utxo_spent (B-29): ask every *other* configured server (never
"""Shared quorum logic behind
corroborate_utxo_spent (B-29), credits and reinstatements: ask every *other* configured server (never
the currently active one — that's exactly what a hostile server or a MITM
would control) and require a strict majority of the ones that actually
answer to agree, via `agrees`, with what our own connection reported.
@@ -749,33 +749,50 @@ class ElectrumListener:
txid, votes = counts.most_common(1)[0]
return txid if votes * 2 > len(answered) else None
async def corroborate_header(self, height: int, expected_hash: str) -> bool:
"""B-28: is `expected_hash` — the header our own active connection
reported for `height` — corroborated by other configured servers before
the draw (rounds/scheduler.py:_wait_for_next_block) treats it as
trustworthy entropy? Without this, a single hostile server (or a MITM on
the one active connection) can single-handedly decide who wins every
round; this raises the bar to controlling a majority of the configured
servers. See _corroborate_majority for the shared quorum logic.
async def agreed_block_hash(self, height: int) -> tuple[str | None, str | None]:
"""B-73 (RND-16, ELC-15): the hash of the block at exactly `height` — the draw's
entropy — as the *quorum* agrees on it, and what the active server said about it.
The header at that height (`blockchain.block.header`) is asked of every configured
server, the active one included, each over its own connection and within ELC-14's
timeouts. A header only counts if it is 80 bytes of valid work (ELC-11). The hash is
returned only when at least the quorum minimum of the *other* servers answered and a
strict majority of everybody who answered names the same one; otherwise the first
element is None and the caller asks again for **the same height** — the draw never
moves to a different block, so whoever relays blocks cannot pick one. The second
element is the active server's own answer (None if it gave none), for the audit row
written on a disagreement.
The old rule asked only the *other* servers to confirm the block the active one had
announced first after the close, which still let the active server choose which real
block seeded the draw simply by never announcing another.
"""
async def _ask(endpoint: ElectrumEndpoint) -> str | None:
client = self._client_factory(endpoint)
try:
await asyncio.wait_for(client.connect(), timeout=_CORROBORATION_TIMEOUT_SECONDS)
result = await asyncio.wait_for(
client.request("blockchain.block.header", [height]),
timeout=_CORROBORATION_TIMEOUT_SECONDS,
)
if not isinstance(result, str):
return None
return header_hex_to_block_hash(result)
except Exception:
async def _ask(client: ElectrumClient) -> str | None:
header = await client.get_block_header(height)
if not isinstance(header, str) or not header_meets_its_own_target(header):
return None
finally:
await client.close()
return header_hex_to_block_hash(header)
return await self._corroborate_majority(_ask, lambda block_hash: block_hash == expected_hash, f"block {height} header")
answers = await self._ask_everyone(_ask)
reported = answers.active
if len(answers.answered_others) < self._minimum_other_answers():
logger.warning(
"block %s: %s of the other servers answered, %s needed", height, len(answers.answered_others),
self._minimum_other_answers(),
)
return None, reported
votes = Counter(answers.all)
if not votes:
return None, reported
block_hash, count = votes.most_common(1)[0]
if count * 2 <= len(answers.all):
return None, reported
if reported is not None and reported != block_hash:
logger.warning(
"block %s: the active server reported %s but the other servers agree on %s", height, reported, block_hash
)
return block_hash, reported
async def corroborate_utxo_spent(self, scripthash: str, txid: str, vout: int) -> bool:
"""B-29: before deposits/service.py's find_utxos_missing_from candidates
+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:
+2
View File
@@ -83,6 +83,8 @@
<input id="admin-round-cooldown" inputmode="numeric" placeholder="es. 30">
<label for="admin-draw-animation">Durata animazione estrazione (secondi)</label>
<input id="admin-draw-animation" inputmode="numeric" placeholder="es. 20">
<label for="admin-draw-confirmations">Conferme sul blocco di estrazione (1–20, dal round successivo)</label>
<input id="admin-draw-confirmations" inputmode="numeric" placeholder="es. 3">
<label for="admin-fee-rate">Fee rate di rete (sat/vB)</label>
<input id="admin-fee-rate" inputmode="numeric" placeholder="es. 1">
<label for="admin-rbf-timeout">Timeout prima del fee-bump RBF (secondi)</label>
+2
View File
@@ -150,6 +150,7 @@ async function adminLoadConfig() {
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
renderMaintenanceState(data.paused);
@@ -209,6 +210,7 @@ async function adminSave() {
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
};
+7
View File
@@ -149,6 +149,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': 'You have {pending_plm} PLM pending confirmation — it is not spendable yet.',
'error.amount_below_network_fee': 'The amount is too small to cover the network fee.',
'error.invalid_address': 'Not a valid PLM address (it must start with plm1q…).',
'error.round_not_found': 'Round not found.',
'error.amount_below_minimum': 'The minimum withdrawal amount is {minimum_plm} PLM.',
'error.current_password_incorrect': 'The current password is incorrect.',
'error.password_too_short': 'The new password must be at least {minimum} characters.',
@@ -316,6 +317,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': 'Hai {pending_plm} PLM in attesa di conferma — non ancora disponibili per la spesa.',
'error.amount_below_network_fee': "L'importo è troppo basso per coprire la fee di rete.",
'error.invalid_address': 'Indirizzo PLM non valido (deve iniziare con plm1q…).',
'error.round_not_found': 'Round non trovato.',
'error.amount_below_minimum': "L'importo minimo di prelievo è {minimum_plm} PLM.",
'error.current_password_incorrect': 'La password attuale non è corretta.',
'error.password_too_short': 'La nuova password deve avere almeno {minimum} caratteri.',
@@ -483,6 +485,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': 'Tienes {pending_plm} PLM pendientes de confirmación — todavía no se pueden gastar.',
'error.amount_below_network_fee': 'El importe es demasiado pequeño para cubrir la comisión de red.',
'error.invalid_address': 'Dirección PLM no válida (debe empezar por plm1q…).',
'error.round_not_found': 'Ronda no encontrada.',
'error.amount_below_minimum': 'El importe mínimo de retiro es {minimum_plm} PLM.',
'error.current_password_incorrect': 'La contraseña actual no es correcta.',
'error.password_too_short': 'La nueva contraseña debe tener al menos {minimum} caracteres.',
@@ -650,6 +653,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': 'Vous avez {pending_plm} PLM en attente de confirmation — pas encore disponibles.',
'error.amount_below_network_fee': 'Le montant est trop faible pour couvrir les frais de réseau.',
'error.invalid_address': 'Adresse PLM invalide (elle doit commencer par plm1q…).',
'error.round_not_found': 'Manche introuvable.',
'error.amount_below_minimum': 'Le montant minimum de retrait est de {minimum_plm} PLM.',
'error.current_password_incorrect': "Le mot de passe actuel n'est pas correct.",
'error.password_too_short': 'Le nouveau mot de passe doit comporter au moins {minimum} caractères.',
@@ -817,6 +821,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': 'Sie haben {pending_plm} PLM, die noch auf Bestätigung warten — noch nicht verfügbar.',
'error.amount_below_network_fee': 'Der Betrag ist zu klein, um die Netzwerkgebühr zu decken.',
'error.invalid_address': 'Keine gültige PLM-Adresse (sie muss mit plm1q… beginnen).',
'error.round_not_found': 'Runde nicht gefunden.',
'error.amount_below_minimum': 'Der Mindestauszahlungsbetrag beträgt {minimum_plm} PLM.',
'error.current_password_incorrect': 'Das aktuelle Passwort ist nicht korrekt.',
'error.password_too_short': 'Das neue Passwort muss mindestens {minimum} Zeichen lang sein.',
@@ -984,6 +989,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': 'У вас есть {pending_plm} PLM, ожидающих подтверждения — они пока недоступны для расходования.',
'error.amount_below_network_fee': 'Сумма слишком мала, чтобы покрыть комиссию сети.',
'error.invalid_address': 'Некорректный адрес PLM (он должен начинаться с plm1q…).',
'error.round_not_found': 'Раунд не найден.',
'error.amount_below_minimum': 'Минимальная сумма вывода — {minimum_plm} PLM.',
'error.current_password_incorrect': 'Текущий пароль указан неверно.',
'error.password_too_short': 'Новый пароль должен содержать не менее {minimum} символов.',
@@ -1151,6 +1157,7 @@ const TRANSLATIONS = {
'error.balance_pending_confirmation': '您有 {pending_plm} PLM 待确认 —— 尚不可用于支出。',
'error.amount_below_network_fee': '金额太小,不足以支付网络手续费。',
'error.invalid_address': 'PLM 地址无效(必须以 plm1q… 开头)。',
'error.round_not_found': '未找到该轮次。',
'error.amount_below_minimum': '最低提现金额为 {minimum_plm} PLM。',
'error.current_password_incorrect': '当前密码不正确。',
'error.password_too_short': '新密码长度至少需要 {minimum} 个字符。',
+11
View File
@@ -9,3 +9,14 @@ def encrypt_xprv(xprv_base58: str) -> bytes:
def decrypt_xprv(token: bytes) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token).decode()
def encrypt_round_seed(server_seed_hex: str) -> str:
"""B-73 (DAT-64, SEC-56): a round's secret encrypted at rest with the same key that
protects the master xprv, so a copy of the database alone never reveals it. The Fernet
token is ASCII, stored as text."""
return Fernet(settings.xprv_encryption_key).encrypt(server_seed_hex.encode()).decode()
def decrypt_round_seed(token: str) -> str:
return Fernet(settings.xprv_encryption_key).decrypt(token.encode()).decode()
+5
View File
@@ -82,6 +82,11 @@ class InsufficientFundsError(Exception):
self.params = params
# B-73 (DAT-63): the recipient is always the first output of a single-recipient transaction
# (the change, when there is one, follows it) — which is what a bet records as `bet_vout`.
RECIPIENT_VOUT = 0
@dataclass
class Utxo:
txid: str