From 30bde96b6e6ed82cca5de805837ea6396b82c37f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 21 Jul 2026 15:05:40 +0200 Subject: [PATCH] Move all business/round parameters into DB config, out of env entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoundConfig gains round_duration_seconds, round_cooldown_seconds, min_amount_sats, fee_rate_sat_vb and rbf_timeout_seconds (plus a hardcoded default for the pre-existing bet_amount_sats) as column defaults on the model itself — get_round_config no longer seeds from Settings at all. Every call site that read these from settings (scheduler, bets, withdrawals, rounds service/route, RBF bumper) now reads the DB-backed RoundConfig instead. app/config.py now holds only true env-driven infra/secrets (database URL, Electrum connection, master key, JWT, admin token) — no business parameter has an env var anymore, matching an explicit decision to drop the "seed from settings" indirection entirely rather than keep a env fallback nobody should rely on. Migration backfills the existing round_config row via server_default (matching the old settings defaults) then drops the default, so future rows go through the ORM/model defaults instead of a stale constant. Tests updated: should_bump's timeout_seconds is now required (no settings fallback); test_scheduler.py seeds a RoundConfig row directly instead of monkeypatching settings; test_withdrawals.py and test_rounds_service.py use local constants mirroring the model defaults instead of reading them off settings. Co-Authored-By: Claude Sonnet 5 --- .env.example | 5 +- app/api/routes/rounds.py | 3 +- app/bets/service.py | 11 ++-- app/config.py | 13 ++-- app/db/models.py | 15 +++-- app/rounds/config.py | 10 ++-- app/rounds/scheduler.py | 8 +-- app/rounds/service.py | 5 +- app/tx/broadcast.py | 14 ++--- app/withdrawals/service.py | 11 ++-- ..._add_operational_params_to_round_config.py | 59 +++++++++++++++++++ tests/unit/test_rounds_service.py | 5 +- tests/unit/test_scheduler.py | 7 +-- tests/unit/test_withdrawals.py | 5 +- 14 files changed, 119 insertions(+), 52 deletions(-) create mode 100644 migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py diff --git a/.env.example b/.env.example index eb5d3d6..1d59a38 100644 --- a/.env.example +++ b/.env.example @@ -17,5 +17,6 @@ JWT_SECRET= # python -c "import secrets; print(secrets.token_urlsafe(32))" ADMIN_TOKEN= -ROUND_DURATION_SECONDS=600 -ROUND_COOLDOWN_SECONDS=30 +# Every business/round parameter (bet amount, round duration/cooldown, min +# amount, fee rate, RBF timeout, fee address) is configured live from the +# admin panel (/admin) instead of here — see docs/guida-admin.md. diff --git a/app/api/routes/rounds.py b/app/api/routes/rounds.py index b18b1bb..ec34c8c 100644 --- a/app/api/routes/rounds.py +++ b/app/api/routes/rounds.py @@ -5,7 +5,6 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.db.models import RoundParticipant from app.db.session import get_session from app.rounds.config import get_round_config @@ -36,7 +35,7 @@ async def current_round(session: AsyncSession = Depends(get_session)) -> Current select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id) ) or 0 opened_at = round_.opened_at.replace(tzinfo=timezone.utc) - closes_at = opened_at + timedelta(seconds=settings.round_duration_seconds) + closes_at = opened_at + timedelta(seconds=config.round_duration_seconds) await session.commit() return CurrentRoundResponse( diff --git a/app/bets/service.py b/app/bets/service.py index dc3e906..5d53cab 100644 --- a/app/bets/service.py +++ b/app/bets/service.py @@ -5,7 +5,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.log import write_audit_log -from app.config import settings from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent from app.electrum.client import ElectrumClient from app.rounds.config import get_round_config @@ -57,7 +56,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - to_address=derive_pool_address(), amount_sats=bet_amount, change_address=user.address, - fee_rate_sat_vb=settings.fee_rate_sat_vb, + fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: raise BetError(str(exc)) from exc @@ -80,7 +79,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - status="broadcast", ) session.add(participant) - session.add(_pending_transaction(round_.id, user.id, built)) + session.add(_pending_transaction(round_.id, user.id, built, config.fee_rate_sat_vb)) await write_audit_log( session, "bet_placed", @@ -94,13 +93,15 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) - return participant -def _pending_transaction(round_id: int, user_id: int, built: BuiltTransaction) -> PendingTransaction: +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=settings.fee_rate_sat_vb, + fee_rate_sat_vb=fee_rate_sat_vb, raw_tx_hex=built.raw_hex, status="pending", ) diff --git a/app/config.py b/app/config.py index aef5b37..3ad1b52 100644 --- a/app/config.py +++ b/app/config.py @@ -17,14 +17,11 @@ class Settings(BaseSettings): jwt_expire_minutes: int = 60 * 24 admin_token: str = "" - round_duration_seconds: int = 600 - round_cooldown_seconds: int = 30 - - bet_amount_sats: int = 10 * 100_000_000 - min_amount_sats: int = 1 * 100_000_000 - confirmations_required: int = 1 - fee_rate_sat_vb: int = 1 - rbf_timeout_seconds: int = 900 + # Every business/round parameter (bet amount, round duration/cooldown, + # min amount, fee rate, RBF timeout, fee address) lives in the round_config + # DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) — + # editable live via the admin panel/API, no env var, no restart. Only true + # infra/secrets belong in this Settings class. settings = Settings() diff --git a/app/db/models.py b/app/db/models.py index 12790ba..c94bb1d 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -74,15 +74,22 @@ class RoundParticipant(Base): class RoundConfig(Base): """Single-row operational config, DB-backed so it's editable without a redeploy. - round_duration is intentionally NOT here: it stays env-var-driven per spec. - Don't move it here without an explicit decision to change that. - """ + Everything business/round-related lives here (round timing, bet amount, fee + rate, RBF timeout, minimum amount) so an operator can tune it live. Secrets + and infra wiring (master key, JWT secret, Electrum host, admin token, + database URL) deliberately stay env-var-driven — those require a restart + anyway and aren't safe to hot-swap.""" __tablename__ = "round_config" id: Mapped[int] = mapped_column(primary_key=True) fee_address: Mapped[str] = mapped_column(String(128)) - bet_amount_sats: Mapped[int] = mapped_column(BigInteger) + 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) + 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) updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow) diff --git a/app/rounds/config.py b/app/rounds/config.py index 55bee3f..bebf2e3 100644 --- a/app/rounds/config.py +++ b/app/rounds/config.py @@ -1,17 +1,17 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.db.models import RoundConfig async def get_round_config(session: AsyncSession) -> RoundConfig: - """Single-row operational config, lazily seeded from settings defaults on - first use. fee_address starts empty until an operator sets it (admin - endpoint, stage 10) — payouts must refuse to run until it's set.""" + """Single-row operational config, lazily created on first use with the + column defaults declared on RoundConfig itself (app/db/models.py) — no env + var involved. fee_address starts empty until an operator sets it via the + admin panel/API — payouts must refuse to run until it's set.""" config = await session.scalar(select(RoundConfig)) if config is None: - config = RoundConfig(fee_address="", bet_amount_sats=settings.bet_amount_sats) + config = RoundConfig(fee_address="") session.add(config) await session.flush() return config diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py index a06617e..3cbc563 100644 --- a/app/rounds/scheduler.py +++ b/app/rounds/scheduler.py @@ -7,7 +7,6 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import async_sessionmaker from app.audit.log import write_audit_log -from app.config import settings from app.db.models import PendingTransaction, Round, RoundParticipant, User from app.electrum.listener import ElectrumListener from app.electrum.scripthash import address_to_scripthash @@ -53,12 +52,13 @@ class RoundScheduler: if round_ is None: return # still in the cooldown window after the last round closed round_id, status, opened_at = round_.id, round_.status, round_.opened_at + round_duration_seconds = (await get_round_config(session)).round_duration_seconds if status != "open": return # already closing/drawing/paying_out; progress happens elsewhere opened_at = opened_at.replace(tzinfo=timezone.utc) - if datetime.now(timezone.utc) < opened_at + timedelta(seconds=settings.round_duration_seconds): + if datetime.now(timezone.utc) < opened_at + timedelta(seconds=round_duration_seconds): return async with self._session_factory() as session: @@ -175,7 +175,7 @@ class RoundScheduler: fee_address=config.fee_address, commission_sats=commission_share, change_address=pool_address, - fee_rate_sat_vb=settings.fee_rate_sat_vb, + fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError: logger.exception("round %s payout failed: insufficient pool UTXOs", round_id) @@ -191,7 +191,7 @@ class RoundScheduler: kind="payout", round_id=round_id, current_txid=built.txid, - fee_rate_sat_vb=settings.fee_rate_sat_vb, + fee_rate_sat_vb=config.fee_rate_sat_vb, raw_tx_hex=built.raw_hex, status="pending", ) diff --git a/app/rounds/service.py b/app/rounds/service.py index ca428d7..dcdb217 100644 --- a/app/rounds/service.py +++ b/app/rounds/service.py @@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.db.models import Round +from app.rounds.config import get_round_config _ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out") @@ -30,7 +30,8 @@ async def open_new_round_if_needed(session: AsyncSession) -> Round | None: last_closed = await session.scalar(select(Round).where(Round.status == "closed").order_by(Round.id.desc())) if last_closed is not None and last_closed.closed_at is not None: closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc) - if datetime.now(timezone.utc) < closed_at + timedelta(seconds=settings.round_cooldown_seconds): + cooldown_seconds = (await get_round_config(session)).round_cooldown_seconds + if datetime.now(timezone.utc) < closed_at + timedelta(seconds=cooldown_seconds): return None round_ = Round(status="open") diff --git a/app/tx/broadcast.py b/app/tx/broadcast.py index 821fe6e..b050b15 100644 --- a/app/tx/broadcast.py +++ b/app/tx/broadcast.py @@ -9,9 +9,9 @@ from embit.finalizer import finalize_psbt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.config import settings from app.db.models import PendingTransaction, User from app.electrum.client import ElectrumClient +from app.rounds.config import get_round_config from app.wallet.hd import derive_pool_key, derive_user_key from app.wallet.plm_network import PLM_MAINNET from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize @@ -26,14 +26,13 @@ class RbfError(Exception): pass -def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int | None = None) -> bool: +def should_bump(pending: PendingTransaction, now: datetime, timeout_seconds: int) -> bool: """Pure decision: has this pending tx been unconfirmed for longer than the - configured timeout? Kept separate from the I/O-heavy bump_fee() so it's - trivially unit-testable.""" - timeout = timeout_seconds if timeout_seconds is not None else settings.rbf_timeout_seconds + configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the + I/O-heavy bump_fee() so it's trivially unit-testable.""" if pending.status != "pending": return False - return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout) + return now >= pending.broadcast_at.replace(tzinfo=timezone.utc) + timedelta(seconds=timeout_seconds) async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple: @@ -145,10 +144,11 @@ class RbfBumper: async def _tick(self, client: ElectrumClient) -> None: now = datetime.now(timezone.utc) async with self._session_factory() as session: + timeout_seconds = (await get_round_config(session)).rbf_timeout_seconds candidates = ( await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending")) ).all() - due = [p for p in candidates if should_bump(p, now)] + due = [p for p in candidates if should_bump(p, now, timeout_seconds=timeout_seconds)] for pending in due: async with self._session_factory() as session: diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index 8e1a76c..e1e374d 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -3,9 +3,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.log import write_audit_log -from app.config import settings from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal from app.electrum.client import ElectrumClient +from app.rounds.config import get_round_config from app.wallet.balance import recompute_balance from app.wallet.hd import derive_user_key from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction @@ -18,8 +18,9 @@ class WithdrawalError(Exception): async def request_withdrawal( session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int ) -> Withdrawal: - if amount_sats < settings.min_amount_sats: - raise WithdrawalError(f"amount below the minimum of {settings.min_amount_sats} sats") + config = await get_round_config(session) + if amount_sats < config.min_amount_sats: + raise WithdrawalError(f"amount below the minimum of {config.min_amount_sats} sats") unspent = ( await session.scalars( @@ -41,7 +42,7 @@ async def request_withdrawal( to_address=external_address, amount_sats=amount_sats, change_address=user.address, - fee_rate_sat_vb=settings.fee_rate_sat_vb, + fee_rate_sat_vb=config.fee_rate_sat_vb, ) except InsufficientFundsError as exc: raise WithdrawalError(str(exc)) from exc @@ -69,7 +70,7 @@ async def request_withdrawal( withdrawal_id=withdrawal.id, user_id=user.id, current_txid=built.txid, - fee_rate_sat_vb=settings.fee_rate_sat_vb, + fee_rate_sat_vb=config.fee_rate_sat_vb, raw_tx_hex=built.raw_hex, status="pending", ) diff --git a/migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py b/migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py new file mode 100644 index 0000000..08b8e82 --- /dev/null +++ b/migrations/versions/53cc70d16e63_add_operational_params_to_round_config.py @@ -0,0 +1,59 @@ +"""add operational params to round_config + +Revision ID: 53cc70d16e63 +Revises: 274efdcbfbcc +Create Date: 2026-07-21 14:44:09.866407 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '53cc70d16e63' +down_revision: Union[str, Sequence[str], None] = '274efdcbfbcc' +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) with the same + # defaults app/config.py used before these became DB-editable; dropped right + # after so new rows go through the ORM defaults instead of a stale constant. + op.add_column( + 'round_config', sa.Column('round_duration_seconds', sa.Integer(), nullable=False, server_default='600') + ) + op.add_column( + 'round_config', sa.Column('round_cooldown_seconds', sa.Integer(), nullable=False, server_default='30') + ) + op.add_column( + 'round_config', + sa.Column('min_amount_sats', sa.BigInteger(), nullable=False, server_default='100000000'), + ) + op.add_column( + 'round_config', sa.Column('fee_rate_sat_vb', sa.Integer(), nullable=False, server_default='1') + ) + op.add_column( + 'round_config', sa.Column('rbf_timeout_seconds', sa.Integer(), nullable=False, server_default='900') + ) + with op.batch_alter_table('round_config') as batch_op: + batch_op.alter_column('round_duration_seconds', server_default=None) + batch_op.alter_column('round_cooldown_seconds', server_default=None) + batch_op.alter_column('min_amount_sats', server_default=None) + batch_op.alter_column('fee_rate_sat_vb', server_default=None) + batch_op.alter_column('rbf_timeout_seconds', server_default=None) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('round_config', 'rbf_timeout_seconds') + op.drop_column('round_config', 'fee_rate_sat_vb') + op.drop_column('round_config', 'min_amount_sats') + op.drop_column('round_config', 'round_cooldown_seconds') + op.drop_column('round_config', 'round_duration_seconds') + # ### end Alembic commands ### diff --git a/tests/unit/test_rounds_service.py b/tests/unit/test_rounds_service.py index bb2ed8b..3c04de7 100644 --- a/tests/unit/test_rounds_service.py +++ b/tests/unit/test_rounds_service.py @@ -3,11 +3,12 @@ from datetime import datetime, timedelta, timezone import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from app.config import settings from app.db.base import Base from app.db.models import Round from app.rounds.service import get_active_round, open_new_round_if_needed +ROUND_COOLDOWN_SECONDS = 30 # matches RoundConfig.round_cooldown_seconds' column default + @pytest.fixture async def session_factory(): @@ -72,7 +73,7 @@ async def test_withholds_new_round_during_cooldown(session_factory): async def test_opens_new_round_once_cooldown_elapses(session_factory): - stale_close = datetime.now(timezone.utc) - timedelta(seconds=settings.round_cooldown_seconds + 1) + stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1) async with session_factory() as session: session.add(Round(status="closed", closed_at=stale_close)) await session.commit() diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index 05b2791..1aa4a9e 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -4,9 +4,8 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from app.config import settings from app.db.base import Base -from app.db.models import Round +from app.db.models import Round, RoundConfig from app.rounds.scheduler import RoundScheduler @@ -31,8 +30,8 @@ async def test_tick_survives_sqlite_naive_datetime_roundtrip(session_factory, mo it directly against datetime.now(timezone.utc) and crashed with "can't compare offset-naive and offset-aware datetimes" on every tick once a round existed — this must not happen.""" - monkeypatch.setattr(settings, "round_duration_seconds", 3600) # not due yet async with session_factory() as session: + session.add(RoundConfig(fee_address="", round_duration_seconds=3600)) # not due yet session.add(Round(status="open", opened_at=datetime.now(timezone.utc))) await session.commit() @@ -41,9 +40,9 @@ async def test_tick_survives_sqlite_naive_datetime_roundtrip(session_factory, mo async def test_tick_closes_round_with_no_participants_once_due(session_factory, monkeypatch): - monkeypatch.setattr(settings, "round_duration_seconds", 1) past = datetime.now(timezone.utc) - timedelta(seconds=10) async with session_factory() as session: + session.add(RoundConfig(fee_address="", round_duration_seconds=1)) session.add(Round(status="open", opened_at=past)) await session.commit() diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index 57a6a3a..896c488 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -19,6 +19,7 @@ class FakeElectrumClient: EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx" +MIN_AMOUNT_SATS = 100_000_000 # matches RoundConfig.min_amount_sats' column default @pytest.fixture @@ -85,7 +86,7 @@ async def test_withdrawal_rejects_amount_below_minimum(session_factory): async with session_factory() as session: user = await session.get(User, user_id) with pytest.raises(WithdrawalError, match="minimum"): - await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats - 1) + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, MIN_AMOUNT_SATS - 1) async def test_withdrawal_rejects_insufficient_balance(session_factory): @@ -95,4 +96,4 @@ async def test_withdrawal_rejects_insufficient_balance(session_factory): async with session_factory() as session: user = await session.get(User, user_id) with pytest.raises(WithdrawalError, match="insufficient balance"): - await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, settings.min_amount_sats) + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, MIN_AMOUNT_SATS)