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
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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} 个字符。',
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user