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:
+3
-2
@@ -17,5 +17,6 @@ JWT_SECRET=
|
|||||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
ADMIN_TOKEN=
|
ADMIN_TOKEN=
|
||||||
|
|
||||||
ROUND_DURATION_SECONDS=600
|
# Every business/round parameter (bet amount, round duration/cooldown, min
|
||||||
ROUND_COOLDOWN_SECONDS=30
|
# amount, fee rate, RBF timeout, fee address) is configured live from the
|
||||||
|
# admin panel (/admin) instead of here — see docs/guida-admin.md.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import RoundParticipant
|
from app.db.models import RoundParticipant
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.rounds.config import get_round_config
|
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)
|
select(func.count()).select_from(RoundParticipant).where(RoundParticipant.round_id == round_.id)
|
||||||
) or 0
|
) or 0
|
||||||
opened_at = round_.opened_at.replace(tzinfo=timezone.utc)
|
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()
|
await session.commit()
|
||||||
|
|
||||||
return CurrentRoundResponse(
|
return CurrentRoundResponse(
|
||||||
|
|||||||
+6
-5
@@ -5,7 +5,6 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.audit.log import write_audit_log
|
from app.audit.log import write_audit_log
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
from app.db.models import PendingTransaction, RoundParticipant, User, UtxoEvent
|
||||||
from app.electrum.client import ElectrumClient
|
from app.electrum.client import ElectrumClient
|
||||||
from app.rounds.config import get_round_config
|
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(),
|
to_address=derive_pool_address(),
|
||||||
amount_sats=bet_amount,
|
amount_sats=bet_amount,
|
||||||
change_address=user.address,
|
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:
|
except InsufficientFundsError as exc:
|
||||||
raise BetError(str(exc)) from exc
|
raise BetError(str(exc)) from exc
|
||||||
@@ -80,7 +79,7 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
|||||||
status="broadcast",
|
status="broadcast",
|
||||||
)
|
)
|
||||||
session.add(participant)
|
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(
|
await write_audit_log(
|
||||||
session,
|
session,
|
||||||
"bet_placed",
|
"bet_placed",
|
||||||
@@ -94,13 +93,15 @@ async def place_bet(session: AsyncSession, client: ElectrumClient, user: User) -
|
|||||||
return participant
|
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(
|
return PendingTransaction(
|
||||||
kind="bet",
|
kind="bet",
|
||||||
round_id=round_id,
|
round_id=round_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
current_txid=built.txid,
|
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,
|
raw_tx_hex=built.raw_hex,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-8
@@ -17,14 +17,11 @@ class Settings(BaseSettings):
|
|||||||
jwt_expire_minutes: int = 60 * 24
|
jwt_expire_minutes: int = 60 * 24
|
||||||
admin_token: str = ""
|
admin_token: str = ""
|
||||||
|
|
||||||
round_duration_seconds: int = 600
|
# Every business/round parameter (bet amount, round duration/cooldown,
|
||||||
round_cooldown_seconds: int = 30
|
# 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) —
|
||||||
bet_amount_sats: int = 10 * 100_000_000
|
# editable live via the admin panel/API, no env var, no restart. Only true
|
||||||
min_amount_sats: int = 1 * 100_000_000
|
# infra/secrets belong in this Settings class.
|
||||||
confirmations_required: int = 1
|
|
||||||
fee_rate_sat_vb: int = 1
|
|
||||||
rbf_timeout_seconds: int = 900
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+11
-4
@@ -74,15 +74,22 @@ class RoundParticipant(Base):
|
|||||||
class RoundConfig(Base):
|
class RoundConfig(Base):
|
||||||
"""Single-row operational config, DB-backed so it's editable without a redeploy.
|
"""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.
|
Everything business/round-related lives here (round timing, bet amount, fee
|
||||||
Don't move it here without an explicit decision to change that.
|
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"
|
__tablename__ = "round_config"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
fee_address: Mapped[str] = mapped_column(String(128))
|
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)
|
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import RoundConfig
|
from app.db.models import RoundConfig
|
||||||
|
|
||||||
|
|
||||||
async def get_round_config(session: AsyncSession) -> RoundConfig:
|
async def get_round_config(session: AsyncSession) -> RoundConfig:
|
||||||
"""Single-row operational config, lazily seeded from settings defaults on
|
"""Single-row operational config, lazily created on first use with the
|
||||||
first use. fee_address starts empty until an operator sets it (admin
|
column defaults declared on RoundConfig itself (app/db/models.py) — no env
|
||||||
endpoint, stage 10) — payouts must refuse to run until it's set."""
|
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))
|
config = await session.scalar(select(RoundConfig))
|
||||||
if config is None:
|
if config is None:
|
||||||
config = RoundConfig(fee_address="", bet_amount_sats=settings.bet_amount_sats)
|
config = RoundConfig(fee_address="")
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
from app.audit.log import write_audit_log
|
from app.audit.log import write_audit_log
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import PendingTransaction, Round, RoundParticipant, User
|
from app.db.models import PendingTransaction, Round, RoundParticipant, User
|
||||||
from app.electrum.listener import ElectrumListener
|
from app.electrum.listener import ElectrumListener
|
||||||
from app.electrum.scripthash import address_to_scripthash
|
from app.electrum.scripthash import address_to_scripthash
|
||||||
@@ -53,12 +52,13 @@ class RoundScheduler:
|
|||||||
if round_ is None:
|
if round_ is None:
|
||||||
return # still in the cooldown window after the last round closed
|
return # still in the cooldown window after the last round closed
|
||||||
round_id, status, opened_at = round_.id, round_.status, round_.opened_at
|
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":
|
if status != "open":
|
||||||
return # already closing/drawing/paying_out; progress happens elsewhere
|
return # already closing/drawing/paying_out; progress happens elsewhere
|
||||||
|
|
||||||
opened_at = opened_at.replace(tzinfo=timezone.utc)
|
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
|
return
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
@@ -175,7 +175,7 @@ class RoundScheduler:
|
|||||||
fee_address=config.fee_address,
|
fee_address=config.fee_address,
|
||||||
commission_sats=commission_share,
|
commission_sats=commission_share,
|
||||||
change_address=pool_address,
|
change_address=pool_address,
|
||||||
fee_rate_sat_vb=settings.fee_rate_sat_vb,
|
fee_rate_sat_vb=config.fee_rate_sat_vb,
|
||||||
)
|
)
|
||||||
except InsufficientFundsError:
|
except InsufficientFundsError:
|
||||||
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
|
logger.exception("round %s payout failed: insufficient pool UTXOs", round_id)
|
||||||
@@ -191,7 +191,7 @@ class RoundScheduler:
|
|||||||
kind="payout",
|
kind="payout",
|
||||||
round_id=round_id,
|
round_id=round_id,
|
||||||
current_txid=built.txid,
|
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,
|
raw_tx_hex=built.raw_hex,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import Round
|
from app.db.models import Round
|
||||||
|
from app.rounds.config import get_round_config
|
||||||
|
|
||||||
_ACTIVE_STATUSES = ("open", "closing", "drawing", "paying_out")
|
_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()))
|
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:
|
if last_closed is not None and last_closed.closed_at is not None:
|
||||||
closed_at = last_closed.closed_at.replace(tzinfo=timezone.utc)
|
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
|
return None
|
||||||
|
|
||||||
round_ = Round(status="open")
|
round_ = Round(status="open")
|
||||||
|
|||||||
+7
-7
@@ -9,9 +9,9 @@ from embit.finalizer import finalize_psbt
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import PendingTransaction, User
|
from app.db.models import PendingTransaction, User
|
||||||
from app.electrum.client import ElectrumClient
|
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.hd import derive_pool_key, derive_user_key
|
||||||
from app.wallet.plm_network import PLM_MAINNET
|
from app.wallet.plm_network import PLM_MAINNET
|
||||||
from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize
|
from app.wallet.psbt_builder import RBF_SEQUENCE, estimate_vsize
|
||||||
@@ -26,14 +26,13 @@ class RbfError(Exception):
|
|||||||
pass
|
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
|
"""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
|
configured timeout (RoundConfig.rbf_timeout_seconds)? Kept separate from the
|
||||||
trivially unit-testable."""
|
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
|
|
||||||
if pending.status != "pending":
|
if pending.status != "pending":
|
||||||
return False
|
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:
|
async def _signing_context(session: AsyncSession, pending: PendingTransaction) -> tuple:
|
||||||
@@ -145,10 +144,11 @@ class RbfBumper:
|
|||||||
async def _tick(self, client: ElectrumClient) -> None:
|
async def _tick(self, client: ElectrumClient) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
|
timeout_seconds = (await get_round_config(session)).rbf_timeout_seconds
|
||||||
candidates = (
|
candidates = (
|
||||||
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
await session.scalars(select(PendingTransaction).where(PendingTransaction.status == "pending"))
|
||||||
).all()
|
).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:
|
for pending in due:
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.audit.log import write_audit_log
|
from app.audit.log import write_audit_log
|
||||||
from app.config import settings
|
|
||||||
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
|
from app.db.models import PendingTransaction, User, UtxoEvent, Withdrawal
|
||||||
from app.electrum.client import ElectrumClient
|
from app.electrum.client import ElectrumClient
|
||||||
|
from app.rounds.config import get_round_config
|
||||||
from app.wallet.balance import recompute_balance
|
from app.wallet.balance import recompute_balance
|
||||||
from app.wallet.hd import derive_user_key
|
from app.wallet.hd import derive_user_key
|
||||||
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction
|
from app.wallet.psbt_builder import InsufficientFundsError, Utxo, build_signed_transaction
|
||||||
@@ -18,8 +18,9 @@ class WithdrawalError(Exception):
|
|||||||
async def request_withdrawal(
|
async def request_withdrawal(
|
||||||
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
|
session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int
|
||||||
) -> Withdrawal:
|
) -> Withdrawal:
|
||||||
if amount_sats < settings.min_amount_sats:
|
config = await get_round_config(session)
|
||||||
raise WithdrawalError(f"amount below the minimum of {settings.min_amount_sats} sats")
|
if amount_sats < config.min_amount_sats:
|
||||||
|
raise WithdrawalError(f"amount below the minimum of {config.min_amount_sats} sats")
|
||||||
|
|
||||||
unspent = (
|
unspent = (
|
||||||
await session.scalars(
|
await session.scalars(
|
||||||
@@ -41,7 +42,7 @@ async def request_withdrawal(
|
|||||||
to_address=external_address,
|
to_address=external_address,
|
||||||
amount_sats=amount_sats,
|
amount_sats=amount_sats,
|
||||||
change_address=user.address,
|
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:
|
except InsufficientFundsError as exc:
|
||||||
raise WithdrawalError(str(exc)) from exc
|
raise WithdrawalError(str(exc)) from exc
|
||||||
@@ -69,7 +70,7 @@ async def request_withdrawal(
|
|||||||
withdrawal_id=withdrawal.id,
|
withdrawal_id=withdrawal.id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
current_txid=built.txid,
|
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,
|
raw_tx_hex=built.raw_hex,
|
||||||
status="pending",
|
status="pending",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -3,11 +3,12 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.models import Round
|
from app.db.models import Round
|
||||||
from app.rounds.service import get_active_round, open_new_round_if_needed
|
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
|
@pytest.fixture
|
||||||
async def session_factory():
|
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):
|
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:
|
async with session_factory() as session:
|
||||||
session.add(Round(status="closed", closed_at=stale_close))
|
session.add(Round(status="closed", closed_at=stale_close))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import pytest
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db.base import Base
|
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
|
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
|
it directly against datetime.now(timezone.utc) and crashed with
|
||||||
"can't compare offset-naive and offset-aware datetimes" on every tick once a
|
"can't compare offset-naive and offset-aware datetimes" on every tick once a
|
||||||
round existed — this must not happen."""
|
round existed — this must not happen."""
|
||||||
monkeypatch.setattr(settings, "round_duration_seconds", 3600) # not due yet
|
|
||||||
async with session_factory() as session:
|
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)))
|
session.add(Round(status="open", opened_at=datetime.now(timezone.utc)))
|
||||||
await session.commit()
|
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):
|
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)
|
past = datetime.now(timezone.utc) - timedelta(seconds=10)
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
|
session.add(RoundConfig(fee_address="", round_duration_seconds=1))
|
||||||
session.add(Round(status="open", opened_at=past))
|
session.add(Round(status="open", opened_at=past))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FakeElectrumClient:
|
|||||||
|
|
||||||
|
|
||||||
EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
|
EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
|
||||||
|
MIN_AMOUNT_SATS = 100_000_000 # matches RoundConfig.min_amount_sats' column default
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -85,7 +86,7 @@ async def test_withdrawal_rejects_amount_below_minimum(session_factory):
|
|||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
with pytest.raises(WithdrawalError, match="minimum"):
|
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):
|
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:
|
async with session_factory() as session:
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
with pytest.raises(WithdrawalError, match="insufficient balance"):
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user