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
+3 -2
View File
@@ -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.
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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:
+6 -5
View File
@@ -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 -2
View File
@@ -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()
+3 -4
View File
@@ -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()
+3 -2
View File
@@ -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)