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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 19:25:15 +02:00
co-authored by Claude Sonnet 5
parent 6c51a81f0e
commit 7b3555f8eb
8 changed files with 44 additions and 23 deletions
+1 -1
View File
@@ -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
-3
View File
@@ -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
+1 -2
View File
@@ -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
-5
View File
@@ -250,8 +250,6 @@
<input id="admin-fee-address" class="mono" placeholder="plm1q...">
<label for="admin-bet-amount">Bet amount (PLM)</label>
<input id="admin-bet-amount" inputmode="decimal" placeholder="es. 10">
<label for="admin-min-amount">Importo minimo deposito/prelievo (PLM)</label>
<input id="admin-min-amount" inputmode="decimal" placeholder="es. 1">
</div>
<div>
<label for="admin-round-duration">Durata round (secondi)</label>
@@ -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),
};
+2 -2
View File
@@ -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(
+2 -3
View File
@@ -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.
@@ -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)
+7 -7
View File
@@ -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)