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:
@@ -85,7 +85,7 @@ Mainnet:
|
|||||||
|
|
||||||
- Bet cost per round: **10 PLM** by default, admin-configurable (`RoundConfig.bet_amount_sats`) — not a fixed constant.
|
- 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.
|
- 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.
|
- 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
|
## What is PLM Lottery
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ _CONFIG_FIELDS = (
|
|||||||
"bet_amount_sats",
|
"bet_amount_sats",
|
||||||
"round_duration_seconds",
|
"round_duration_seconds",
|
||||||
"round_cooldown_seconds",
|
"round_cooldown_seconds",
|
||||||
"min_amount_sats",
|
|
||||||
"fee_rate_sat_vb",
|
"fee_rate_sat_vb",
|
||||||
"rbf_timeout_seconds",
|
"rbf_timeout_seconds",
|
||||||
"draw_animation_seconds",
|
"draw_animation_seconds",
|
||||||
@@ -40,7 +39,6 @@ class RoundConfigResponse(BaseModel):
|
|||||||
bet_amount_sats: int
|
bet_amount_sats: int
|
||||||
round_duration_seconds: int
|
round_duration_seconds: int
|
||||||
round_cooldown_seconds: int
|
round_cooldown_seconds: int
|
||||||
min_amount_sats: int
|
|
||||||
fee_rate_sat_vb: int
|
fee_rate_sat_vb: int
|
||||||
rbf_timeout_seconds: int
|
rbf_timeout_seconds: int
|
||||||
draw_animation_seconds: int
|
draw_animation_seconds: int
|
||||||
@@ -52,7 +50,6 @@ class RoundConfigUpdate(BaseModel):
|
|||||||
bet_amount_sats: int | None = None
|
bet_amount_sats: int | None = None
|
||||||
round_duration_seconds: int | None = None
|
round_duration_seconds: int | None = None
|
||||||
round_cooldown_seconds: int | None = None
|
round_cooldown_seconds: int | None = None
|
||||||
min_amount_sats: int | None = None
|
|
||||||
fee_rate_sat_vb: int | None = None
|
fee_rate_sat_vb: int | None = None
|
||||||
rbf_timeout_seconds: int | None = None
|
rbf_timeout_seconds: int | None = None
|
||||||
draw_animation_seconds: int | None = None
|
draw_animation_seconds: int | None = None
|
||||||
|
|||||||
+1
-2
@@ -75,7 +75,7 @@ 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.
|
||||||
|
|
||||||
Everything business/round-related lives here (round timing, bet amount, fee
|
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,
|
and infra wiring (master key, JWT secret, Electrum host, admin token,
|
||||||
database URL) deliberately stay env-var-driven — those require a restart
|
database URL) deliberately stay env-var-driven — those require a restart
|
||||||
anyway and aren't safe to hot-swap."""
|
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
|
# 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.
|
# its entropy (rounds/scheduler.py) — that can take longer than this value.
|
||||||
draw_animation_seconds: Mapped[int] = mapped_column(default=20)
|
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)
|
fee_rate_sat_vb: Mapped[int] = mapped_column(default=1)
|
||||||
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
rbf_timeout_seconds: Mapped[int] = mapped_column(default=900)
|
||||||
# Maintenance switch: when true, the round currently in progress still runs to
|
# Maintenance switch: when true, the round currently in progress still runs to
|
||||||
|
|||||||
@@ -250,8 +250,6 @@
|
|||||||
<input id="admin-fee-address" class="mono" placeholder="plm1q...">
|
<input id="admin-fee-address" class="mono" placeholder="plm1q...">
|
||||||
<label for="admin-bet-amount">Bet amount (PLM)</label>
|
<label for="admin-bet-amount">Bet amount (PLM)</label>
|
||||||
<input id="admin-bet-amount" inputmode="decimal" placeholder="es. 10">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label for="admin-round-duration">Durata round (secondi)</label>
|
<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-duration').value = data.round_duration_seconds;
|
||||||
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
||||||
document.getElementById('admin-draw-animation').value = data.draw_animation_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-fee-rate').value = data.fee_rate_sat_vb;
|
||||||
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
||||||
renderMaintenanceState(data.paused);
|
renderMaintenanceState(data.paused);
|
||||||
@@ -533,14 +530,12 @@ async function adminSave() {
|
|||||||
const btn = document.getElementById('save-btn');
|
const btn = document.getElementById('save-btn');
|
||||||
const feeAddress = document.getElementById('admin-fee-address').value;
|
const feeAddress = document.getElementById('admin-fee-address').value;
|
||||||
const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value);
|
const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value);
|
||||||
const minAmountPlm = parseFloat(document.getElementById('admin-min-amount').value);
|
|
||||||
const body = {
|
const body = {
|
||||||
fee_address: feeAddress,
|
fee_address: feeAddress,
|
||||||
bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM),
|
bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM),
|
||||||
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
|
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
|
||||||
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
||||||
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').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),
|
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
||||||
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ 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:
|
||||||
config = await get_round_config(session)
|
config = await get_round_config(session)
|
||||||
if amount_sats < config.min_amount_sats:
|
if amount_sats < config.bet_amount_sats:
|
||||||
raise WithdrawalError(f"amount below the minimum of {config.min_amount_sats} sats")
|
raise WithdrawalError(f"amount below the minimum of {config.bet_amount_sats} sats")
|
||||||
|
|
||||||
unspent = (
|
unspent = (
|
||||||
await session.scalars(
|
await session.scalars(
|
||||||
|
|||||||
+2
-3
@@ -37,11 +37,10 @@ business — quelli si toccano solo da qui.
|
|||||||
| Campo | Significato |
|
| 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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
|
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
|
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
|
sat/vB, RBF timeout 900s) — vanno
|
||||||
comunque rivisti e confermati dal pannello prima del primo utilizzo reale.
|
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)
|
||||||
@@ -19,7 +19,7 @@ class FakeElectrumClient:
|
|||||||
|
|
||||||
|
|
||||||
EXTERNAL_ADDRESS = "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx"
|
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
|
@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):
|
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()
|
client = FakeElectrumClient()
|
||||||
|
|
||||||
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)
|
||||||
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 client.broadcasted
|
||||||
assert withdrawal.status == "broadcast"
|
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:
|
async with session_factory() as session:
|
||||||
user = await session.get(User, user_id)
|
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):
|
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()
|
client = FakeElectrumClient()
|
||||||
|
|
||||||
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, 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):
|
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:
|
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, MIN_AMOUNT_SATS)
|
await request_withdrawal(session, client, user, EXTERNAL_ADDRESS, BET_AMOUNT_SATS)
|
||||||
|
|||||||
Reference in New Issue
Block a user