Move all business/round parameters into DB config, out of env entirely

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:05:40 +02:00
co-authored by Claude Sonnet 5
parent 48a9eeb839
commit 30bde96b6e
14 changed files with 119 additions and 52 deletions
+1 -2
View File
@@ -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(
+6 -5
View File
@@ -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",
)
+5 -8
View File
@@ -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()
+11 -4
View File
@@ -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)
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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",
)
+3 -2
View File
@@ -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")
+7 -7
View File
@@ -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:
+6 -5
View File
@@ -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",
)