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
+54
-9
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user