2026-07-23 10:52:07 +02:00
|
|
|
import asyncio
|
|
|
|
|
import json
|
2026-07-22 14:07:09 +02:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-07-21 10:57:31 +02:00
|
|
|
|
2026-07-22 10:16:51 +02:00
|
|
|
from fastapi import APIRouter, Depends, Request
|
2026-07-23 10:52:07 +02:00
|
|
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
2026-07-21 10:57:31 +02:00
|
|
|
from pydantic import BaseModel
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-07-27 14:44:15 +02:00
|
|
|
from app.api.client_ip import client_ip
|
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)
2026-10-14 16:29:09 +02:00
|
|
|
from app.api.errors import http_error
|
2026-07-27 14:12:27 +02:00
|
|
|
from app.api.timeutil import isoformat_utc
|
2026-07-23 10:09:12 +02:00
|
|
|
from app.auth.dependencies import get_optional_user
|
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)
2026-10-14 16:29:09 +02:00
|
|
|
from app.db.models import Round, RoundParticipant, User
|
2026-07-21 10:57:31 +02:00
|
|
|
from app.db.session import get_session
|
|
|
|
|
from app.rounds.config import get_round_config
|
2026-07-27 14:44:15 +02:00
|
|
|
from app.rounds.events import EVICTED, RoundEventCapacityError, broadcaster
|
2026-08-04 14:13:00 +02:00
|
|
|
from app.rounds.service import get_active_round, round_deadline, rounds_can_open, winner_share
|
2026-07-21 10:57:31 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/rounds", tags=["rounds"])
|
|
|
|
|
|
2026-07-23 10:52:07 +02:00
|
|
|
# How often request.is_disconnected() gets (re-)checked while idle — bounds
|
|
|
|
|
# how long a subscriber slot lingers after a client goes away without a clean
|
|
|
|
|
# TCP close (e.g. the network just vanishes). Kept short since the check
|
|
|
|
|
# itself is cheap; it does NOT control how often anything is sent on the wire.
|
|
|
|
|
_SSE_DISCONNECT_CHECK_SECONDS = 5
|
|
|
|
|
|
|
|
|
|
# How often to send an SSE keep-alive comment on an otherwise-idle connection —
|
|
|
|
|
# well under any reasonable reverse-proxy/load-balancer idle-connection timeout
|
|
|
|
|
# (Caddy's default is 5 minutes) so the stream isn't silently dropped. Expressed
|
|
|
|
|
# as a multiple of the disconnect-check interval above.
|
|
|
|
|
_SSE_KEEPALIVE_TICKS = 4 # 4 * 5s = 20s between keep-alive comments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/stream")
|
|
|
|
|
async def round_stream(request: Request) -> Response:
|
|
|
|
|
"""Server-Sent Events channel: pushes a content-free "update" notification
|
|
|
|
|
the instant round/bet/balance state changes anywhere (see app/rounds/events.py
|
|
|
|
|
for the publish() call sites), instead of clients only finding out on their
|
|
|
|
|
next poll. No payload and no auth: it's a public "go refetch" signal, and
|
|
|
|
|
the actual data still comes from the normal per-user REST endpoints, which
|
|
|
|
|
is where authorization and personalization (e.g. user_played) already live.
|
|
|
|
|
|
|
|
|
|
Frontend polling (app/static/index.html) is left in place as a fallback —
|
|
|
|
|
this is purely additive, so a dropped/blocked SSE connection degrades to
|
|
|
|
|
the pre-existing polling behavior rather than losing updates outright.
|
|
|
|
|
That's also what happens past MAX_SUBSCRIBERS (app/rounds/events.py): this
|
|
|
|
|
returns 503 rather than opening a stream, and the browser's EventSource
|
|
|
|
|
just retries later while the frontend keeps working off polling meanwhile.
|
2026-07-27 14:44:15 +02:00
|
|
|
|
|
|
|
|
Concurrent streams are additionally capped per client IP (B-38): past
|
|
|
|
|
MAX_SUBSCRIBERS_PER_IP, opening one more evicts that IP's own oldest
|
|
|
|
|
connection rather than refusing the new one or letting a single source
|
|
|
|
|
exhaust the global cap and degrade every other user.
|
2026-07-23 10:52:07 +02:00
|
|
|
"""
|
|
|
|
|
try:
|
2026-07-27 14:44:15 +02:00
|
|
|
queue = broadcaster.subscribe(client_ip(request))
|
2026-07-23 10:52:07 +02:00
|
|
|
except RoundEventCapacityError:
|
|
|
|
|
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
|
|
|
|
|
|
|
|
|
|
async def event_generator():
|
|
|
|
|
ticks_since_keepalive = 0
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
if await request.is_disconnected():
|
|
|
|
|
break
|
|
|
|
|
try:
|
2026-07-27 14:44:15 +02:00
|
|
|
item = await asyncio.wait_for(queue.get(), timeout=_SSE_DISCONNECT_CHECK_SECONDS)
|
|
|
|
|
if item is EVICTED:
|
|
|
|
|
break # this IP opened another stream past its per-IP cap
|
2026-07-23 10:52:07 +02:00
|
|
|
yield "event: update\ndata: {}\n\n".format(json.dumps({}))
|
|
|
|
|
ticks_since_keepalive = 0
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
ticks_since_keepalive += 1
|
|
|
|
|
if ticks_since_keepalive >= _SSE_KEEPALIVE_TICKS:
|
|
|
|
|
yield ": keep-alive\n\n"
|
|
|
|
|
ticks_since_keepalive = 0
|
|
|
|
|
finally:
|
|
|
|
|
broadcaster.unsubscribe(queue)
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
event_generator(),
|
|
|
|
|
media_type="text/event-stream",
|
|
|
|
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-21 10:57:31 +02:00
|
|
|
|
|
|
|
|
class CurrentRoundResponse(BaseModel):
|
2026-07-22 14:07:09 +02:00
|
|
|
server_time: str
|
2026-07-21 10:57:31 +02:00
|
|
|
round_id: int | None = None
|
|
|
|
|
status: str | None = None
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
# B-80 (RND-35): this round's own pool address, so anyone can verify its money on
|
|
|
|
|
# an explorer; null when there is no active round.
|
|
|
|
|
pool_address: str | None = None
|
2026-07-21 10:57:31 +02:00
|
|
|
opened_at: str | None = None
|
|
|
|
|
closes_at: str | None = None
|
2026-08-04 11:52:10 +02:00
|
|
|
# B-65: confirmed participants only — the ones the draw actually picks from and
|
|
|
|
|
# whose sats are actually in the pool. The pending_* pair below is the same
|
|
|
|
|
# confirmed/in-flight split the balance already exposes (see
|
|
|
|
|
# wallet/balance.py's balance_sats vs pending_balance_sats), and for the same
|
|
|
|
|
# reason: the authoritative number must be the one that will be paid, while the
|
|
|
|
|
# player who just bet still needs to see their own bet somewhere.
|
2026-07-21 10:57:31 +02:00
|
|
|
participant_count: int = 0
|
|
|
|
|
bet_amount_sats: int
|
|
|
|
|
jackpot_sats: int = 0
|
2026-08-04 11:52:10 +02:00
|
|
|
# Inclusive of bets still building/broadcast, exactly like pending_balance_sats
|
|
|
|
|
# is inclusive of unconfirmed change — not deltas. Equal to the confirmed
|
|
|
|
|
# figures above when nothing is in flight, which is what has_pending_bets says.
|
|
|
|
|
pending_participant_count: int = 0
|
|
|
|
|
pending_jackpot_sats: int = 0
|
|
|
|
|
has_pending_bets: bool = False
|
2026-07-21 16:04:02 +02:00
|
|
|
draw_animation_seconds: int
|
|
|
|
|
winner_user_id: int | None = None
|
|
|
|
|
winner_amount_sats: int | None = None
|
2026-07-22 23:20:55 +02:00
|
|
|
draw_block_height: int | None = None
|
|
|
|
|
draw_block_hash: str | None = None
|
2026-07-27 14:12:27 +02:00
|
|
|
# B-36: set only while status == "drawing", so the frontend can show "still
|
|
|
|
|
# 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
|
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)
2026-10-14 16:29:09 +02:00
|
|
|
# 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
|
2026-07-22 10:16:51 +02:00
|
|
|
chain_tip_height: int | None = None
|
2026-07-22 10:36:36 +02:00
|
|
|
lottery_paused: bool = False
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
paused_reason: str | None = None # B-78 (DAT-69): operator / resurrected_tx / ..., None if not paused
|
2026-08-04 14:13:00 +02:00
|
|
|
# B-66: false while the instance is missing configuration a round cannot run
|
|
|
|
|
# without (today: fee_address) — no round will open until it's set, so this is
|
|
|
|
|
# the difference between "wait, the next round is coming" and "nothing is coming
|
|
|
|
|
# until the operator finishes setting this up". Distinct from lottery_paused,
|
|
|
|
|
# which is a deliberate operator action rather than an unmet prerequisite.
|
|
|
|
|
lottery_configured: bool = True
|
2026-07-23 10:09:12 +02:00
|
|
|
user_played: bool = False
|
2026-07-21 10:57:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/current", response_model=CurrentRoundResponse)
|
2026-07-23 10:09:12 +02:00
|
|
|
async def current_round(
|
|
|
|
|
request: Request,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
user: User | None = Depends(get_optional_user),
|
|
|
|
|
) -> CurrentRoundResponse:
|
2026-07-21 10:57:31 +02:00
|
|
|
config = await get_round_config(session)
|
|
|
|
|
round_ = await get_active_round(session)
|
2026-07-22 10:16:51 +02:00
|
|
|
listener = request.app.state.electrum_listener
|
|
|
|
|
chain_tip_height = listener.tip_height or None
|
2026-07-21 10:57:31 +02:00
|
|
|
if round_ is None:
|
|
|
|
|
await session.commit()
|
2026-07-21 16:04:02 +02:00
|
|
|
return CurrentRoundResponse(
|
2026-07-22 14:07:09 +02:00
|
|
|
server_time=datetime.now(timezone.utc).isoformat(),
|
2026-07-22 10:16:51 +02:00
|
|
|
bet_amount_sats=config.bet_amount_sats,
|
|
|
|
|
draw_animation_seconds=config.draw_animation_seconds,
|
|
|
|
|
chain_tip_height=chain_tip_height,
|
2026-07-22 10:36:36 +02:00
|
|
|
lottery_paused=config.paused,
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
paused_reason=config.paused_reason,
|
2026-08-04 14:13:00 +02:00
|
|
|
lottery_configured=rounds_can_open(config),
|
2026-07-21 16:04:02 +02:00
|
|
|
)
|
2026-07-21 10:57:31 +02:00
|
|
|
|
2026-07-27 00:32:22 +02:00
|
|
|
# The pool is the sum of what the participants' bets actually paid into the pool
|
|
|
|
|
# address — each one is already net of that bet's network fee. Deriving it from
|
|
|
|
|
# participant_count * the *current* bet_amount_sats instead overstated it, and
|
|
|
|
|
# silently changed the advertised jackpot of a round in progress whenever an
|
|
|
|
|
# operator edited the bet amount (B-11).
|
2026-08-04 11:52:10 +02:00
|
|
|
#
|
|
|
|
|
# Split confirmed from in-flight (B-65): the draw only picks from confirmed
|
|
|
|
|
# participants and the payout only spends their sats, so counting every row
|
|
|
|
|
# advertised a jackpot larger than the one that would be paid, and made a
|
|
|
|
|
# participant appear and then vanish again if their bet was later abandoned.
|
|
|
|
|
counts = (
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(
|
|
|
|
|
func.count(),
|
|
|
|
|
func.coalesce(func.sum(RoundParticipant.bet_amount_sats), 0),
|
|
|
|
|
func.count().filter(RoundParticipant.status == "confirmed"),
|
|
|
|
|
func.coalesce(
|
|
|
|
|
func.sum(RoundParticipant.bet_amount_sats).filter(
|
|
|
|
|
RoundParticipant.status == "confirmed"
|
|
|
|
|
),
|
|
|
|
|
0,
|
|
|
|
|
),
|
|
|
|
|
).where(RoundParticipant.round_id == round_.id)
|
2026-07-27 00:32:22 +02:00
|
|
|
)
|
2026-08-04 11:52:10 +02:00
|
|
|
).one()
|
|
|
|
|
all_count, all_pool_sats, participant_count, pool_amount_sats = counts
|
2026-07-21 10:57:31 +02:00
|
|
|
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
2026-08-03 23:25:56 +02:00
|
|
|
# B-61: from the round's own duration — the countdown clients are watching must
|
|
|
|
|
# not jump because an operator edited the config mid-round.
|
|
|
|
|
closes_at = round_deadline(round_)
|
2026-07-23 10:09:12 +02:00
|
|
|
|
|
|
|
|
# Lets the frontend show the personalized win/lose reveal only to players in
|
|
|
|
|
# this round — everyone else (not logged in, or logged in but didn't bet)
|
|
|
|
|
# just sees the generic phase progress instead of a "non hai vinto" that
|
|
|
|
|
# wouldn't mean anything to them.
|
|
|
|
|
user_played = False
|
|
|
|
|
if user is not None:
|
|
|
|
|
user_played = (
|
|
|
|
|
await session.scalar(
|
|
|
|
|
select(RoundParticipant).where(
|
|
|
|
|
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
) is not None
|
|
|
|
|
|
2026-07-21 10:57:31 +02:00
|
|
|
await session.commit()
|
|
|
|
|
|
2026-07-27 00:32:22 +02:00
|
|
|
# Shown to players as "jackpot": the winner's 70% share of the pool (same split
|
|
|
|
|
# rounds/scheduler.py applies at payout time), not the full pool. It remains an
|
|
|
|
|
# upper bound by the payout tx's own fee, which is deducted from the winner's
|
|
|
|
|
# share and isn't knowable until the payout is built — a few hundred sat on a
|
|
|
|
|
# 1 sat/vB payout, i.e. invisible at PLM amounts, but it is not exact.
|
2026-07-31 15:04:33 +02:00
|
|
|
jackpot_sats = winner_share(pool_amount_sats)
|
2026-07-22 14:53:09 +02:00
|
|
|
|
2026-07-21 10:57:31 +02:00
|
|
|
return CurrentRoundResponse(
|
2026-07-22 14:07:09 +02:00
|
|
|
server_time=datetime.now(timezone.utc).isoformat(),
|
2026-07-21 10:57:31 +02:00
|
|
|
round_id=round_.id,
|
|
|
|
|
status=round_.status,
|
Give every round its own pool address (B-80, RND-35, DAT-60, DAT-11, REG-14, SEC-08, BET-12, RND-22, WDR-14, SEC-43, API-23, API-36)
2026-10-14 13:52:32 +02:00
|
|
|
pool_address=round_.pool_address or None,
|
2026-07-21 10:57:31 +02:00
|
|
|
opened_at=opened_at.isoformat(),
|
|
|
|
|
closes_at=closes_at.isoformat(),
|
|
|
|
|
participant_count=participant_count,
|
|
|
|
|
bet_amount_sats=config.bet_amount_sats,
|
2026-07-22 14:53:09 +02:00
|
|
|
jackpot_sats=jackpot_sats,
|
2026-08-04 11:52:10 +02:00
|
|
|
pending_participant_count=all_count,
|
|
|
|
|
pending_jackpot_sats=winner_share(all_pool_sats),
|
|
|
|
|
has_pending_bets=all_count > participant_count,
|
2026-07-21 16:04:02 +02:00
|
|
|
draw_animation_seconds=config.draw_animation_seconds,
|
|
|
|
|
winner_user_id=round_.winner_user_id,
|
|
|
|
|
winner_amount_sats=round_.winner_amount_sats,
|
2026-07-22 23:20:55 +02:00
|
|
|
draw_block_height=round_.draw_block_height,
|
|
|
|
|
draw_block_hash=round_.draw_block_hash,
|
2026-07-27 14:12:27 +02:00
|
|
|
draw_waiting_since=isoformat_utc(round_.drawing_started_at) if round_.status == "drawing" else None,
|
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)
2026-10-14 16:29:09 +02:00
|
|
|
server_seed_hash=round_.server_seed_hash,
|
|
|
|
|
participants_hash=round_.participants_hash,
|
|
|
|
|
draw_height=round_.draw_height,
|
|
|
|
|
draw_confirmations=round_.draw_confirmations,
|
2026-07-22 10:16:51 +02:00
|
|
|
chain_tip_height=chain_tip_height,
|
2026-07-22 10:36:36 +02:00
|
|
|
lottery_paused=config.paused,
|
Abandon a transaction only on proof, and keep watching failed rows (B-78, TX-15, TX-17, TX-23, TX-26, ELC-28, DAT-33, DAT-36, DAT-69, DAT-72, AUD-11, AUD-12, AUD-31, AUD-39, API-32)
2026-10-14 15:07:09 +02:00
|
|
|
paused_reason=config.paused_reason,
|
2026-08-04 14:13:00 +02:00
|
|
|
lottery_configured=rounds_can_open(config),
|
2026-07-23 10:09:12 +02:00
|
|
|
user_played=user_played,
|
2026-07-21 10:57:31 +02:00
|
|
|
)
|
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)
2026-10-14 16:29:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|