From 669c3fb714437653055a7bc2c6c482ad7555065d Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 21 Jul 2026 16:04:02 +0200 Subject: [PATCH] Add draw_animation_seconds config and expose winner info during a round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoundConfig gains draw_animation_seconds (default 20) — the minimum time the frontend's "estrazione in corso" animation plays before revealing a winner. It's purely a UI cue: the real draw still waits for a confirmed block for its entropy (rounds/scheduler.py), which usually takes much longer than this value, so it only ever extends the animation, never truncates the real wait. GET /rounds/current now also returns draw_animation_seconds, winner_user_id and winner_amount_sats (all populated once the scheduler sets them on the round, i.e. from "paying_out" onward) so a client can determine and reveal the outcome. GET /users/me now returns the user's own id, needed client-side to compare against winner_user_id. Admin config CRUD refactored to a shared field tuple (_CONFIG_FIELDS) instead of repeating the same 7-then-8 field list three times. Co-Authored-By: Claude Sonnet 5 --- app/api/routes/admin.py | 34 ++++++++--------- app/api/routes/rounds.py | 10 ++++- app/api/routes/users.py | 5 ++- app/db/models.py | 5 +++ ...67_add_draw_animation_seconds_to_round_.py | 37 +++++++++++++++++++ 5 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 migrations/versions/1db52f3a7c67_add_draw_animation_seconds_to_round_.py diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 9dd2644..9e7af95 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -20,6 +20,18 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None: raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token") +_CONFIG_FIELDS = ( + "fee_address", + "bet_amount_sats", + "round_duration_seconds", + "round_cooldown_seconds", + "min_amount_sats", + "fee_rate_sat_vb", + "rbf_timeout_seconds", + "draw_animation_seconds", +) + + class RoundConfigResponse(BaseModel): fee_address: str bet_amount_sats: int @@ -28,6 +40,7 @@ class RoundConfigResponse(BaseModel): min_amount_sats: int fee_rate_sat_vb: int rbf_timeout_seconds: int + draw_animation_seconds: int class RoundConfigUpdate(BaseModel): @@ -38,18 +51,11 @@ class RoundConfigUpdate(BaseModel): min_amount_sats: int | None = None fee_rate_sat_vb: int | None = None rbf_timeout_seconds: int | None = None + draw_animation_seconds: int | None = None def _config_response(config) -> RoundConfigResponse: - return RoundConfigResponse( - fee_address=config.fee_address, - bet_amount_sats=config.bet_amount_sats, - round_duration_seconds=config.round_duration_seconds, - round_cooldown_seconds=config.round_cooldown_seconds, - min_amount_sats=config.min_amount_sats, - fee_rate_sat_vb=config.fee_rate_sat_vb, - rbf_timeout_seconds=config.rbf_timeout_seconds, - ) + return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_FIELDS}) @router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)]) @@ -64,15 +70,7 @@ async def update_config( body: RoundConfigUpdate, session: AsyncSession = Depends(get_session) ) -> RoundConfigResponse: config = await get_round_config(session) - for field in ( - "fee_address", - "bet_amount_sats", - "round_duration_seconds", - "round_cooldown_seconds", - "min_amount_sats", - "fee_rate_sat_vb", - "rbf_timeout_seconds", - ): + for field in _CONFIG_FIELDS: value = getattr(body, field) if value is not None: setattr(config, field, value) diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index ec34c8c..41605e7 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -21,6 +21,9 @@ class CurrentRoundResponse(BaseModel): participant_count: int = 0 bet_amount_sats: int jackpot_sats: int = 0 + draw_animation_seconds: int + winner_user_id: int | None = None + winner_amount_sats: int | None = None @router.get("/current", response_model=CurrentRoundResponse) @@ -29,7 +32,9 @@ async def current_round(session: AsyncSession = Depends(get_session)) -> Current round_ = await get_active_round(session) if round_ is None: await session.commit() - return CurrentRoundResponse(bet_amount_sats=config.bet_amount_sats) + return CurrentRoundResponse( + bet_amount_sats=config.bet_amount_sats, draw_animation_seconds=config.draw_animation_seconds + ) participant_count = await session.scalar( select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id) @@ -46,4 +51,7 @@ async def current_round(session: AsyncSession = Depends(get_session)) -> Current participant_count=participant_count, bet_amount_sats=config.bet_amount_sats, jackpot_sats=participant_count * config.bet_amount_sats, + draw_animation_seconds=config.draw_animation_seconds, + winner_user_id=round_.winner_user_id, + winner_amount_sats=round_.winner_amount_sats, ) diff --git a/app/api/routes/users.py b/app/api/routes/users.py index c8973a4..745b843 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -8,6 +8,7 @@ router = APIRouter(prefix="/users", tags=["users"]) class MeResponse(BaseModel): + id: int username: str address: str balance_sats: int @@ -15,4 +16,6 @@ class MeResponse(BaseModel): @router.get("/me", response_model=MeResponse) async def me(user: User = Depends(get_current_user)) -> MeResponse: - return MeResponse(username=user.username, address=user.address, balance_sats=user.cached_balance_sats) + return MeResponse( + id=user.id, username=user.username, address=user.address, balance_sats=user.cached_balance_sats + ) diff --git a/app/db/models.py b/app/db/models.py index c94bb1d..9bf6dec 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -87,6 +87,11 @@ class RoundConfig(Base): bet_amount_sats: Mapped[int] = mapped_column(BigInteger, default=1_000_000_000) round_duration_seconds: Mapped[int] = mapped_column(default=600) round_cooldown_seconds: Mapped[int] = mapped_column(default=30) + # Purely a frontend cue: the minimum time the "estrazione in corso" animation + # plays for on every user's dashboard before the winner can be revealed. Does + # 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) min_amount_sats: Mapped[int] = mapped_column(BigInteger, default=100_000_000) fee_rate_sat_vb: Mapped[int] = mapped_column(default=1) rbf_timeout_seconds: Mapped[int] = mapped_column(default=900) diff --git a/migrations/versions/1db52f3a7c67_add_draw_animation_seconds_to_round_.py b/migrations/versions/1db52f3a7c67_add_draw_animation_seconds_to_round_.py new file mode 100644 index 0000000..e9c7b79 --- /dev/null +++ b/migrations/versions/1db52f3a7c67_add_draw_animation_seconds_to_round_.py @@ -0,0 +1,37 @@ +"""add draw_animation_seconds to round_config + +Revision ID: 1db52f3a7c67 +Revises: 53cc70d16e63 +Create Date: 2026-07-21 15:45:16.434942 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1db52f3a7c67' +down_revision: Union[str, Sequence[str], None] = '53cc70d16e63' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # server_default backfills the existing singleton row (if any); dropped right + # after so new rows go through the ORM default instead of a stale constant. + op.add_column( + 'round_config', sa.Column('draw_animation_seconds', sa.Integer(), nullable=False, server_default='20') + ) + with op.batch_alter_table('round_config') as batch_op: + batch_op.alter_column('draw_animation_seconds', server_default=None) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('round_config', 'draw_animation_seconds') + # ### end Alembic commands ###