Frontend dashboards previously found out about state changes only on their next poll tick (up to 15s, or 3s during a draw) — this adds a push channel so updates land as soon as they happen instead. - app/rounds/events.py: a small in-process pub/sub (RoundEventBroadcaster). The message carries no payload — it's just a "something changed, go refetch" signal, so it needs no auth and no knowledge of who's allowed to see what; personalization stays entirely in the existing REST endpoints. - GET /rounds/stream: an SSE endpoint exposing that channel, with keep-alive comments so it survives idle periods behind a reverse proxy, and a defensive MAX_SUBSCRIBERS cap (well above the ~100 concurrent users expected) — past it, the endpoint returns 503 instead of opening a stream, and callers just keep working off polling. - publish() calls added at every point that actually changes what a dashboard would want to know: new round opened, round status transitions (closing/drawing/paying_out/closed), a bet or withdrawal broadcast, any pending tx confirming (bet/withdrawal/payout), a deposit credited, and a new block tip arriving (the exact moment the "drawing" phase is waiting on). - Caddyfile: excludes /rounds/stream from gzip encoding, since compression would buffer output and defeat the point of a live stream. Single-process only by design for now (no cross-worker fan-out) and the notification is a generic broadcast rather than a per-user channel — both are deliberate scope decisions for the current ~100-user, single-container deployment, not oversights. Polling is left fully in place as a fallback; this is purely additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
3.7 KiB
Python
111 lines
3.7 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.events import broadcaster
|
|
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)
|
|
broadcaster.publish() # participant_count/jackpot changed — nudge every dashboard to refetch
|
|
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",
|
|
)
|