From 7b3555f8eb8bc1a10db9eaeb0d0aeb9f13056733 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 22 Jul 2026 19:25:15 +0200 Subject: [PATCH] Tie the withdrawal minimum to the bet amount instead of a separate field RoundConfig.min_amount_sats was an independently-configurable floor that could drift out of sync with bet_amount_sats for no real reason (deposits never had a server-side minimum anyway). Drop the field and enforce amount_sats >= config.bet_amount_sats directly in request_withdrawal. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- app/api/routes/admin.py | 3 -- app/db/models.py | 3 +- app/static/admin.html | 5 --- app/withdrawals/service.py | 4 +-- docs/guida-admin.md | 5 ++- .../6cb50b29f64c_drop_min_amount_sats.py | 31 +++++++++++++++++++ tests/unit/test_withdrawals.py | 14 ++++----- 8 files changed, 44 insertions(+), 23 deletions(-) create mode 100644 migrations/versions/6cb50b29f64c_drop_min_amount_sats.py diff --git a/CLAUDE.md b/CLAUDE.md index 38c2981..8c8e2f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ Mainnet: - Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant. - Prize split: **70% winner / 30% fees**, hardcoded in `rounds/scheduler.py` (`winner_share = pool_amount_sats * 70 // 100`) — unlike bet amount, this ratio is not in `RoundConfig` and would need a code change, not an admin-panel edit. -- Minimum withdrawal amount: **1 PLM** by default, admin-configurable (`RoundConfig.min_amount_sats`) — a business-friendly floor, above the network's technical dust limit. Deposits have no server-side minimum check. +- Minimum withdrawal amount: equal to the current bet amount (`RoundConfig.bet_amount_sats`), enforced in `app/withdrawals/service.py` — not a separate admin-configurable field. Deposits have no server-side minimum check. - Confirmations required for all tx types (deposit, bet, payout, withdrawal): **1**, hardcoded in `tx/confirmation.py` — not configurable, per the design decision below. ## What is PLM Lottery diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 153a1ac..26b08f7 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -27,7 +27,6 @@ _CONFIG_FIELDS = ( "bet_amount_sats", "round_duration_seconds", "round_cooldown_seconds", - "min_amount_sats", "fee_rate_sat_vb", "rbf_timeout_seconds", "draw_animation_seconds", @@ -40,7 +39,6 @@ class RoundConfigResponse(BaseModel): bet_amount_sats: int round_duration_seconds: int round_cooldown_seconds: int - min_amount_sats: int fee_rate_sat_vb: int rbf_timeout_seconds: int draw_animation_seconds: int @@ -52,7 +50,6 @@ class RoundConfigUpdate(BaseModel): bet_amount_sats: int | None = None round_duration_seconds: int | None = None round_cooldown_seconds: int | None = None - min_amount_sats: int | None = None fee_rate_sat_vb: int | None = None rbf_timeout_seconds: int | None = None draw_animation_seconds: int | None = None diff --git a/app/db/models.py b/app/db/models.py index 60927dc..e3e85ce 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -75,7 +75,7 @@ class RoundConfig(Base): """Single-row operational config, DB-backed so it's editable without a redeploy. Everything business/round-related lives here (round timing, bet amount, fee - rate, RBF timeout, minimum amount) so an operator can tune it live. Secrets + rate, RBF timeout) 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.""" @@ -92,7 +92,6 @@ class RoundConfig(Base): # NOT gate the actual draw, which still waits for a real confirmed block for # its entropy (rounds/scheduler.py) — that can take longer than this value. draw_animation_seconds: Mapped[int] = mapped_column(default=20) - 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) # Maintenance switch: when true, the round currently in progress still runs to diff --git a/app/static/admin.html b/app/static/admin.html index 929975a..0fcc7dc 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -250,8 +250,6 @@ - -
@@ -479,7 +477,6 @@ async function adminLoadConfig() { document.getElementById('admin-round-duration').value = data.round_duration_seconds; document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds; document.getElementById('admin-draw-animation').value = data.draw_animation_seconds; - document.getElementById('admin-min-amount').value = data.min_amount_sats / SATS_PER_PLM; document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb; document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds; renderMaintenanceState(data.paused); @@ -533,14 +530,12 @@ async function adminSave() { const btn = document.getElementById('save-btn'); const feeAddress = document.getElementById('admin-fee-address').value; const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value); - const minAmountPlm = parseFloat(document.getElementById('admin-min-amount').value); const body = { fee_address: feeAddress, bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM), round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10), round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10), draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10), - min_amount_sats: Math.round(minAmountPlm * SATS_PER_PLM), fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10), rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10), }; diff --git a/app/withdrawals/service.py b/app/withdrawals/service.py index e1e374d..561b2c0 100644 --- a/app/withdrawals/service.py +++ b/app/withdrawals/service.py @@ -19,8 +19,8 @@ async def request_withdrawal( session: AsyncSession, client: ElectrumClient, user: User, external_address: str, amount_sats: int ) -> Withdrawal: 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") + if amount_sats < config.bet_amount_sats: + raise WithdrawalError(f"amount below the minimum of {config.bet_amount_sats} sats") unspent = ( await session.scalars( diff --git a/docs/guida-admin.md b/docs/guida-admin.md index dcd6f5f..75d9339 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -37,11 +37,10 @@ business — quelli si toccano solo da qui. | Campo | Significato | |---|---| | **Fee address** | L'indirizzo PLM su cui finisce il 30% di ogni round (fee). Obbligatorio: i payout **non partono** se questo campo è vuoto. | -| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. | +| **Bet amount (PLM)** | Il costo fisso d'ingresso per round. È anche l'importo minimo prelevabile: un prelievo sotto questa soglia viene rifiutato (i depositi non hanno un controllo minimo lato server). | | **Durata round (secondi)** | Quanto resta aperto un round prima di chiudersi ed estrarre il vincitore. Il taglio per le nuove giocate scatta esattamente allo scadere di questo tempo (verificato ad ogni bet, non dipende dal ciclo dello scheduler) — è un "semaforo giallo": nessuna nuova entrata, ma le bet già trasmesse prima dello scadere hanno comunque tempo di confermarsi prima che il round chiuda ed estragga. | | **Pausa tra un round e il successivo (secondi)** | Cooldown dopo la chiusura di un round, prima che il successivo si apra — dà tempo ai giocatori di vedere l'esito. | | **Durata animazione estrazione (secondi)** | Tempo minimo per cui la dashboard di ogni utente mostra l'animazione "Estrazione in corso" dopo la chiusura del round, prima di rivelare il vincitore. È solo un minimo: il processo reale aspetta fino a 3 blocchi confermati in sequenza (ultima bet in sospeso, estrazione, payout — ~2 minuti l'uno), quindi l'animazione può durare più a lungo di questo valore, mai meno. | -| **Importo minimo deposito/prelievo (PLM)** | Soglia minima per un prelievo (i depositi non hanno un controllo minimo lato server, solo un floor consigliato). | | **Fee rate di rete (sat/vB)** | Fee per byte usata per costruire bet, payout e prelievi. | | **Timeout prima del fee-bump RBF (secondi)** | Dopo quanto tempo senza conferma una transazione viene ritrasmessa con fee più alta. | @@ -50,7 +49,7 @@ solo nella chiamata API — il backend lavora sempre in sats. Su un'istanza nuova (mai avviata), questi campi partono con dei default hardcoded nel codice (`RoundConfig` in `app/db/models.py`: bet 10 PLM, round -10 minuti, cooldown 30s, animazione estrazione 20s, minimo 1 PLM, fee 1 +10 minuti, cooldown 30s, animazione estrazione 20s, fee 1 sat/vB, RBF timeout 900s) — vanno comunque rivisti e confermati dal pannello prima del primo utilizzo reale. diff --git a/migrations/versions/6cb50b29f64c_drop_min_amount_sats.py b/migrations/versions/6cb50b29f64c_drop_min_amount_sats.py new file mode 100644 index 0000000..90433c7 --- /dev/null +++ b/migrations/versions/6cb50b29f64c_drop_min_amount_sats.py @@ -0,0 +1,31 @@ +"""drop min_amount_sats, withdrawal minimum now equals bet amount + +Revision ID: 6cb50b29f64c +Revises: 5f2079b95b33 +Create Date: 2026-07-22 16:50:38.765233 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6cb50b29f64c' +down_revision: Union[str, Sequence[str], None] = '5f2079b95b33' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table('round_config') as batch_op: + batch_op.drop_column('min_amount_sats') + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table('round_config') as batch_op: + batch_op.add_column(sa.Column('min_amount_sats', sa.BigInteger(), nullable=False, server_default='100000000')) + batch_op.alter_column('min_amount_sats', server_default=None) diff --git a/tests/unit/test_withdrawals.py b/tests/unit/test_withdrawals.py index 896c488..b765607 100644 --- a/tests/unit/test_withdrawals.py +++ b/tests/unit/test_withdrawals.py @@ -19,7 +19,7 @@ class FakeElectrumClient: EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx" -MIN_AMOUNT_SATS = 100_000_000 # matches RoundConfig.min_amount_sats' column default +BET_AMOUNT_SATS = 1_000_000_000 # matches RoundConfig.bet_amount_sats' column default; also the withdrawal minimum @pytest.fixture @@ -57,16 +57,16 @@ async def _make_funded_user(session_factory, index: int, funded_sats: int) -> in async def test_withdrawal_broadcasts_and_updates_balance(session_factory): - user_id = await _make_funded_user(session_factory, 0, 500_000_000) + user_id = await _make_funded_user(session_factory, 0, 2_000_000_000) client = FakeElectrumClient() async with session_factory() as session: user = await session.get(User, user_id) - withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, 100_000_000) + withdrawal = await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS) assert client.broadcasted assert withdrawal.status == "broadcast" - assert withdrawal.amount_sent_sats < 100_000_000 # fee deducted from the amount + assert withdrawal.amount_sent_sats < BET_AMOUNT_SATS # fee deducted from the amount async with session_factory() as session: user = await session.get(User, user_id) @@ -80,13 +80,13 @@ async def test_withdrawal_broadcasts_and_updates_balance(session_factory): async def test_withdrawal_rejects_amount_below_minimum(session_factory): - user_id = await _make_funded_user(session_factory, 1, 500_000_000) + user_id = await _make_funded_user(session_factory, 1, 2_000_000_000) client = FakeElectrumClient() 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, MIN_AMOUNT_SATS - 1) + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS - 1) async def test_withdrawal_rejects_insufficient_balance(session_factory): @@ -96,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, MIN_AMOUNT_SATS) + await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)