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))"
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user