mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
configure payment
This commit is contained in:
@@ -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) |
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
|||||||
Reference in New Issue
Block a user