Files
plm-lottery/app/bets/service.py
T
davideandClaude Sonnet 5 7dcf6d2756 Sync round countdown across clients and enforce the bet cutoff on deadline, not scheduler tick
The round timer relied on each client's own wall clock, so two browsers with
skewed local clocks showed different countdowns for the same round; the
server now also returns server_time so the frontend can correct for clock
skew. Also drop out-of-order /rounds/current responses (multiple independent
triggers could resolve late and revert the UI to a stale drawing/result
state) and prune per-round bookkeeping maps on round transitions.

Separately, place_bet only checked status == "open", leaving a window (up to
the scheduler's 5s tick interval) after a round's timer hit zero where a new
bet could still be accepted. place_bet now checks the round's own deadline
directly (round_accepts_bets), acting as an immediate "yellow light" for new
entries while still letting already-broadcast bets confirm before the round
closes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:07:09 +02:00

109 lines
3.6 KiB
Python

from datetime import datetime, timezone
from embit import script
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.audit.log import write_audit_log
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
from app.electrum.client import ElectrumClient
from app.rounds.config import get_round_config
from app.rounds.service import open_new_round_if_needed, round_accepts_bets
from app.wallet.balance import recompute_balance
from app.wallet.hd import derive_pool_address, derive_user_key
from app.wallet.psbt_builder import BuiltTransaction, InsufficientFundsError, Utxo, build_signed_transaction
class BetError(Exception):
pass
async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -> RoundParticipant:
round_ = await open_new_round_if_needed(session)
if round_ is None:
raise BetError("no round open right now, please try again shortly")
config = await get_round_config(session)
if not round_accepts_bets(round_, config.round_duration_seconds):
raise BetError("the current round is closing, please try again shortly")
already_playing = await session.scalar(
select(RoundParticipant).where(
RoundParticipant.round_id == round_.id, RoundParticipant.user_id == user.id
)
)
if already_playing is not None:
raise BetError("you already have an active bet in the current round")
bet_amount = config.bet_amount_sats
unspent = (
await session.scalars(
select(UtxoEvent).where(UtxoEvent.user_id == user.id, UtxoEvent.spent_txid.is_(None))
)
).all()
if sum(u.amount_sats for u in unspent) < bet_amount:
raise BetError("insufficient balance")
user_key = derive_user_key(user.derivation_index)
from_script = script.p2wpkh(user_key.to_public())
utxos = [Utxo(u.txid, u.vout, u.amount_sats) for u in unspent]
try:
built = build_signed_transaction(
signing_key=user_key,
from_script=from_script,
utxos=utxos,
to_address=derive_pool_address(),
amount_sats=bet_amount,
change_address=user.address,
fee_rate_sat_vb=config.fee_rate_sat_vb,
)
except InsufficientFundsError as exc:
raise BetError(str(exc)) from exc
await client.broadcast(built.raw_hex)
spent_by_key = {(u.txid, u.vout): u for u in unspent}
for spent in built.spent_utxos:
row = spent_by_key[(spent.txid, spent.vout)]
row.spent_txid = built.txid
await recompute_balance(session, user.id)
broadcast_at = datetime.now(timezone.utc)
participant = RoundParticipant(
round_id=round_.id,
user_id=user.id,
bet_amount_sats=built.recipient_sats,
bet_txid=built.txid,
broadcast_at=broadcast_at,
status="broadcast",
)
session.add(participant)
session.add(_pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb))
await write_audit_log(
session,
"bet_placed",
{"txid": built.txid, "amount_sats": built.recipient_sats},
user_id=user.id,
round_id=round_.id,
)
await session.commit()
await session.refresh(participant)
return participant
def _pending_transaction(
round_id: int, user_id: int, built: BuiltTransaction, fee_rate_sat_vb: int
) -> PendingTransaction:
return PendingTransaction(
kind="bet",
round_id=round_id,
user_id=user_id,
current_txid=built.txid,
fee_rate_sat_vb=fee_rate_sat_vb,
raw_tx_hex=built.raw_hex,
status="pending",
)