configure payment

This commit is contained in:
9qeklajc
2026-05-09 00:00:18 +02:00
parent 7fd0cdf987
commit 59773e972b
6 changed files with 218 additions and 11 deletions
+11 -3
View File
@@ -78,9 +78,15 @@ Which mints to accept payments from:
Automatic profit withdrawal: Automatic profit withdrawal:
| Setting | Description | | Setting | Description | Default |
| --------------------- | ------------------------------- | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------- |
| **Lightning Address** | Your LN address for withdrawals | | **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 ### Security
@@ -126,6 +132,8 @@ Use environment variables for:
| `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` | | `ENABLE_ANALYTICS_SHARING` | Enable usage analytics sharing to Nostr | `true` |
| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` | | `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` |
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — | | `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` | | `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
| `CORS_ORIGINS` | Allowed CORS origins | `*` | | `CORS_ORIGINS` | Allowed CORS origins | `*` |
| `RELAYS` | Nostr relays (comma-separated) | (default set) | | `RELAYS` | Nostr relays (comma-separated) | (default set) |
+8 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, RootModel from pydantic import BaseModel, RootModel
from pydantic.v1 import ValidationError as PydanticValidationError
from sqlmodel import select from sqlmodel import select
from ..payment.models import _row_to_model, list_models 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: if field in settings_data:
del settings_data[field] del settings_data[field]
async with create_session() as session: try:
new_settings = await SettingsService.update(settings_data, session) 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() data = new_settings.dict()
if "upstream_api_key" in data: if "upstream_api_key" in data:
data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else ""
+9
View File
@@ -41,6 +41,15 @@ class Settings(BaseSettings):
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL") primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT") 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 # Pricing
# Default behavior: derive pricing from MODELS # Default behavior: derive pricing from MODELS
# If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens # If fixed_pricing is True -> use fixed_cost_per_request and ignore tokens
+10 -5
View File
@@ -502,11 +502,11 @@ async def fetch_all_balances(
async def periodic_payout() -> None: 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: 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: try:
async with db.create_session() as session: async with db.create_session() as session:
for mint_url in settings.cashu_mints: for mint_url in settings.cashu_mints:
@@ -524,7 +524,12 @@ async def periodic_payout() -> None:
user_balance = user_balance // 1000 user_balance = user_balance // 1000
proofs_balance = sum(proof.amount for proof in proofs) proofs_balance = sum(proof.amount for proof in proofs)
available_balance = proofs_balance - user_balance 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: if available_balance > min_amount:
amount_received = await raw_send_to_lnurl( amount_received = await raw_send_to_lnurl(
wallet, wallet,
+50 -1
View File
@@ -1,11 +1,12 @@
import os import os
import pytest import pytest
from pydantic.v1 import ValidationError
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import text from sqlmodel import text
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.settings import SettingsService from routstr.core.settings import Settings, SettingsService
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -46,6 +47,54 @@ async def test_settings_db_precedence_over_env() -> None:
assert again.enable_analytics_sharing is False 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 @pytest.mark.asyncio
async def test_settings_initialize_discards_unknown_keys() -> None: async def test_settings_initialize_discards_unknown_keys() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:") engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+130
View File
@@ -32,9 +32,18 @@ interface SettingsData {
onion_url?: string; onion_url?: string;
cashu_mints?: string[]; cashu_mints?: string[];
relays?: string[]; relays?: string[];
receive_ln_address?: string;
min_payout_sat?: number;
payout_interval_seconds?: number;
[key: string]: unknown; [key: string]: unknown;
} }
const PAYOUT_KEYS = [
'receive_ln_address',
'min_payout_sat',
'payout_interval_seconds',
] as const;
const HANDLED_KEYS = [ const HANDLED_KEYS = [
'name', 'name',
'description', 'description',
@@ -48,6 +57,7 @@ const HANDLED_KEYS = [
'admin_password', 'admin_password',
'id', 'id',
'updated_at', 'updated_at',
...PAYOUT_KEYS,
]; ];
const IGNORED_KEYS = [ const IGNORED_KEYS = [
@@ -369,6 +379,7 @@ export function AdminSettings() {
const cashuMintsChanged = hasFieldChanged('cashu_mints'); const cashuMintsChanged = hasFieldChanged('cashu_mints');
const relaysChanged = hasFieldChanged('relays'); const relaysChanged = hasFieldChanged('relays');
const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing'); const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing');
const payoutChanged = PAYOUT_KEYS.some(hasFieldChanged);
const advancedKeys = Object.keys(settings).filter( const advancedKeys = Object.keys(settings).filter(
(key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key) (key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
); );
@@ -401,8 +412,44 @@ export function AdminSettings() {
setNewRelay(''); setNewRelay('');
}; };
const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']); const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']);
const resetPayout = () => resetFields([...PAYOUT_KEYS]);
const resetAdvanced = () => resetFields(advancedKeys); 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) { if (loading) {
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
@@ -620,6 +667,89 @@ export function AdminSettings() {
) : null} ) : null}
</Card> </Card>
{/* Lightning Payout Settings */}
<Card>
<CardHeader>
<CardTitle>Lightning Payout Settings</CardTitle>
<CardDescription>
Tune how node profit is paid out over Lightning. Amounts must be
positive and above your wallet&apos;s minimum-invoice constraints.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{payoutFields.map((field) => {
const value = settings[field.key];
if (field.type === 'number') {
return (
<div key={field.key} className='space-y-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
type='number'
min={field.min}
value={
typeof value === 'number'
? value
: value === undefined || value === null
? ''
: Number(value)
}
placeholder={field.placeholder}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
handleInputChange(field.key, undefined);
} else {
const parsed = Number(raw);
handleInputChange(
field.key,
Number.isFinite(parsed) ? parsed : undefined
);
}
}}
/>
<p className='text-muted-foreground text-xs'>
{field.helpText}
</p>
</div>
);
}
return (
<div key={field.key} className='space-y-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
value={(value as string) || ''}
placeholder={field.placeholder}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
/>
<p className='text-muted-foreground text-xs'>
{field.helpText}
</p>
</div>
);
})}
</CardContent>
{payoutChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetPayout}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Relays */} {/* Relays */}
<Card> <Card>
<CardHeader> <CardHeader>