diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index 20c0e70c..40930676 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -78,9 +78,15 @@ Which mints to accept payments from: Automatic profit withdrawal: -| Setting | Description | -| --------------------- | ------------------------------- | -| **Lightning Address** | Your LN address for withdrawals | +| Setting | Description | Default | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------- | +| **Lightning Address** | Your LN address for withdrawals | — | +| **Minimum Payout (sat)** | Min available balance (in sats) before profit is paid out. Applies to both `sat` and `msat` mints (auto-converted). | `210` | +| **Payout Interval (seconds)** | How often the payout loop wakes up and checks balances | `900` | + +All payout amounts must be positive. Set the minimums above your wallet's +minimum-invoice constraint (typically 1 sat) and high enough to amortise +routing fees. ### Security @@ -126,6 +132,8 @@ Use environment variables for: | `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` | | `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` | | `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — | +| `MIN_PAYOUT_SAT` | Min payout balance in sats (applies to all mints) | `210` | +| `PAYOUT_INTERVAL_SECONDS` | Payout loop interval (seconds) | `900` | | `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` | | `CORS_ORIGINS` | Allowed CORS origins | `*` | | `RELAYS` | Nostr relays (comma-separated) | (default set) | diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 923e597f..7db77a67 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -6,6 +6,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, RootModel +from pydantic.v1 import ValidationError as PydanticValidationError from sqlmodel import select from ..payment.models import _row_to_model, list_models @@ -160,8 +161,13 @@ async def update_settings(request: Request, update: SettingsUpdate) -> dict: if field in settings_data: del settings_data[field] - async with create_session() as session: - new_settings = await SettingsService.update(settings_data, session) + try: + async with create_session() as session: + new_settings = await SettingsService.update(settings_data, session) + except PydanticValidationError as e: + # Surface validation issues (e.g. non-positive payout amounts) + # as a clean 400 instead of a 500. + raise HTTPException(status_code=400, detail=e.errors()) from e data = new_settings.dict() if "upstream_api_key" in data: data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" diff --git a/routstr/core/settings.py b/routstr/core/settings.py index d05364f4..ab8db07d 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -41,6 +41,15 @@ class Settings(BaseSettings): primary_mint: str = Field(default="", env="PRIMARY_MINT_URL") primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT") + # Lightning payout configuration + # Minimum available balance (in satoshis) before profit is paid out over + # Lightning + min_payout_sat: int = Field(default=210, gt=0, env="MIN_PAYOUT_SAT") + # Interval (seconds) between periodic payout attempts. Must be positive. + payout_interval_seconds: int = Field( + default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS" + ) + # Pricing # Default behavior: derive pricing from MODELS # If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens diff --git a/routstr/wallet.py b/routstr/wallet.py index 9000763b..ea56a565 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -502,11 +502,11 @@ async def fetch_all_balances( async def periodic_payout() -> None: - if not settings.receive_ln_address: - logger.warning("RECEIVE_LN_ADDRESS is not set, periodic payout disabled") - return while True: - await asyncio.sleep(60 * 15) + await asyncio.sleep(settings.payout_interval_seconds) + print(settings.payout_interval_seconds) + if not settings.receive_ln_address: + continue try: async with db.create_session() as session: for mint_url in settings.cashu_mints: @@ -524,7 +524,12 @@ async def periodic_payout() -> None: user_balance = user_balance // 1000 proofs_balance = sum(proof.amount for proof in proofs) available_balance = proofs_balance - user_balance - min_amount = 210 if unit == "sat" else 210000 + # Threshold is configured in sats; convert for msat wallets. + min_amount = ( + settings.min_payout_sat + if unit == "sat" + else settings.min_payout_sat * 1000 + ) if available_balance > min_amount: amount_received = await raw_send_to_lnurl( wallet, diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 770af976..ccdb71bc 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -1,11 +1,12 @@ import os import pytest +from pydantic.v1 import ValidationError from sqlalchemy.ext.asyncio import create_async_engine from sqlmodel import text from sqlmodel.ext.asyncio.session import AsyncSession -from routstr.core.settings import SettingsService +from routstr.core.settings import Settings, SettingsService @pytest.mark.asyncio @@ -46,6 +47,54 @@ async def test_settings_db_precedence_over_env() -> None: assert again.enable_analytics_sharing is False +def test_payout_settings_have_sensible_defaults() -> None: + s = Settings() + assert s.min_payout_sat == 210 + assert s.payout_interval_seconds == 900 + + +@pytest.mark.parametrize( + "field,bad_value", + [ + ("min_payout_sat", 0), + ("min_payout_sat", -1), + ("payout_interval_seconds", 0), + ("payout_interval_seconds", -10), + ], +) +def test_payout_settings_reject_invalid_values(field: str, bad_value: int) -> None: + kwargs: dict[str, object] = {field: bad_value} + with pytest.raises(ValidationError): + Settings(**kwargs) # type: ignore[arg-type] + + +def test_payout_settings_accept_custom_positive_values() -> None: + s = Settings(min_payout_sat=500, payout_interval_seconds=60) + assert s.min_payout_sat == 500 + assert s.payout_interval_seconds == 60 + + +@pytest.mark.asyncio +async def test_payout_settings_persist_via_settings_service() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + await SettingsService.initialize(session) + updated = await SettingsService.update( + {"min_payout_sat": 1000, "payout_interval_seconds": 300}, session + ) + assert updated.min_payout_sat == 1000 + assert updated.payout_interval_seconds == 300 + + +@pytest.mark.asyncio +async def test_payout_settings_update_rejects_invalid() -> None: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with AsyncSession(engine, expire_on_commit=False) as session: + await SettingsService.initialize(session) + with pytest.raises(ValidationError): + await SettingsService.update({"min_payout_sat": 0}, session) + + @pytest.mark.asyncio async def test_settings_initialize_discards_unknown_keys() -> None: engine = create_async_engine("sqlite+aiosqlite:///:memory:") diff --git a/ui/components/settings/admin-settings.tsx b/ui/components/settings/admin-settings.tsx index 30e0f79d..f5ee51d9 100644 --- a/ui/components/settings/admin-settings.tsx +++ b/ui/components/settings/admin-settings.tsx @@ -32,9 +32,18 @@ interface SettingsData { onion_url?: string; cashu_mints?: string[]; relays?: string[]; + receive_ln_address?: string; + min_payout_sat?: number; + payout_interval_seconds?: number; [key: string]: unknown; } +const PAYOUT_KEYS = [ + 'receive_ln_address', + 'min_payout_sat', + 'payout_interval_seconds', +] as const; + const HANDLED_KEYS = [ 'name', 'description', @@ -48,6 +57,7 @@ const HANDLED_KEYS = [ 'admin_password', 'id', 'updated_at', + ...PAYOUT_KEYS, ]; const IGNORED_KEYS = [ @@ -369,6 +379,7 @@ export function AdminSettings() { const cashuMintsChanged = hasFieldChanged('cashu_mints'); const relaysChanged = hasFieldChanged('relays'); const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing'); + const payoutChanged = PAYOUT_KEYS.some(hasFieldChanged); const advancedKeys = Object.keys(settings).filter( (key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key) ); @@ -401,8 +412,44 @@ export function AdminSettings() { setNewRelay(''); }; const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']); + const resetPayout = () => resetFields([...PAYOUT_KEYS]); const resetAdvanced = () => resetFields(advancedKeys); + const payoutFields: ReadonlyArray<{ + key: (typeof PAYOUT_KEYS)[number]; + label: string; + placeholder: string; + type: 'text' | 'number'; + helpText: string; + min?: number; + }> = [ + { + key: 'receive_ln_address', + label: 'Lightning Receive Address', + placeholder: 'you@walletofsatoshi.com or LNURL', + type: 'text', + helpText: + 'Lightning address (or LNURL) profits are paid out to. Leave empty to disable periodic payouts.', + }, + { + key: 'min_payout_sat', + label: 'Minimum Payout (sat)', + placeholder: '210', + type: 'number', + min: 1, + helpText: + 'Wallet payouts only fire when at least this many satoshis are available. Must be > 0.', + }, + { + key: 'payout_interval_seconds', + label: 'Payout Interval (seconds)', + placeholder: '900', + type: 'number', + min: 1, + helpText: 'How often the payout loop wakes up to check balances.', + }, + ]; + if (loading) { return (
@@ -620,6 +667,89 @@ export function AdminSettings() { ) : null} + {/* Lightning Payout Settings */} + + + Lightning Payout Settings + + Tune how node profit is paid out over Lightning. Amounts must be + positive and above your wallet's minimum-invoice constraints. + + + + {payoutFields.map((field) => { + const value = settings[field.key]; + if (field.type === 'number') { + return ( +
+ + { + const raw = e.target.value; + if (raw === '') { + handleInputChange(field.key, undefined); + } else { + const parsed = Number(raw); + handleInputChange( + field.key, + Number.isFinite(parsed) ? parsed : undefined + ); + } + }} + /> +

+ {field.helpText} +

+
+ ); + } + return ( +
+ + + handleInputChange(field.key, e.target.value) + } + /> +

+ {field.helpText} +

+
+ ); + })} +
+ {payoutChanged ? ( + +
+ + +
+
+ ) : null} +
+ {/* Relays */}