Compare commits

..
Author SHA1 Message Date
9qeklajc ee79a305ba revert 2026-05-30 17:20:16 +02:00
9qeklajc ed2c8c9fe2 fix compose file 2026-05-30 17:00:57 +02:00
9qeklajcandGitHub 9161256d31 Merge pull request #529 from Routstr/rip-08-lightning-invoice
better handling invoice payment
2026-05-30 16:39:11 +02:00
9qeklajcandGitHub 1a8f407142 Merge pull request #531 from Routstr/remove-secp256-dep
remove secp256k1 dependency
2026-05-29 13:34:31 +02:00
9qeklajc f7bd250c97 better handling invoice payment 2026-05-28 20:09:48 +02:00
7 changed files with 457 additions and 16 deletions
+14 -4
View File
@@ -1,13 +1,23 @@
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libsecp256k1-dev \
autoconf \
automake \
libtool \
&& rm -rf /var/lib/apt/lists/*
COPY uv.lock pyproject.toml ./
RUN mkdir -p /routstr
COPY . .
RUN uv sync --frozen --no-dev --no-install-project
RUN uv sync --frozen --no-dev
WORKDIR /app
COPY . .
ARG GIT_COMMIT=""
ARG GIT_TAG=""
@@ -18,4 +28,4 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
+15 -4
View File
@@ -18,13 +18,24 @@ RUN pnpm run build
# Stage 2: Build the Routstr Node
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libsecp256k1-dev \
autoconf \
automake \
libtool \
&& rm -rf /var/lib/apt/lists/*
COPY uv.lock pyproject.toml ./
RUN uv sync --no-dev --no-install-project
WORKDIR /app
# Copy the rest of the application (required for uv sync to find the package)
COPY . .
RUN uv sync --no-dev
# Copy the built UI from the ui-builder stage
COPY --from=ui-builder /app/ui/out ./ui_out
@@ -38,4 +49,4 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
# Run the application
CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"]
+47
View File
@@ -22,6 +22,7 @@ from .db import (
ApiKey,
CashuTransaction,
CliToken,
LightningInvoice,
ModelRow,
UpstreamProviderRow,
create_session,
@@ -1477,6 +1478,52 @@ async def get_transactions_api(
}
@admin_router.get(
"/api/lightning-invoices", dependencies=[Depends(require_admin_api)]
)
async def get_lightning_invoices_api(
status: str | None = None,
purpose: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict:
async with create_session() as session:
from sqlmodel import col, func
base = select(LightningInvoice)
if status:
base = base.where(LightningInvoice.status == status)
if purpose:
base = base.where(LightningInvoice.purpose == purpose)
if search:
pattern = f"%{search}%"
base = base.where(
(col(LightningInvoice.id).like(pattern))
| (col(LightningInvoice.bolt11).like(pattern))
| (col(LightningInvoice.payment_hash).like(pattern))
| (col(LightningInvoice.api_key_hash).like(pattern))
)
count_result = await session.exec(
select(func.count()).select_from(base.subquery())
)
total = count_result.one()
stmt = (
base.order_by(col(LightningInvoice.created_at).desc())
.offset(offset)
.limit(limit)
)
results = await session.exec(stmt)
invoices = results.all()
return {
"invoices": [inv.dict() for inv in invoices],
"total": total,
}
@admin_router.post(
"/api/upstream-providers/{provider_id}/routstr/refund",
dependencies=[Depends(require_admin_api)],
+7 -1
View File
@@ -13,7 +13,7 @@ from starlette.types import Scope
from ..auth import periodic_key_reset
from ..balance import balance_router, deprecated_wallet_router
from ..lightning import lightning_router
from ..lightning import lightning_router, periodic_invoice_watcher
from ..nostr import (
announce_provider,
providers_cache_refresher,
@@ -57,6 +57,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
auto_topup_task = None
refund_sweep_task = None
routstr_fee_task = None
invoice_watcher_task = None
try:
# Apply litellm-wide settings (drop_params, chat-completions URL,
@@ -124,6 +125,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
auto_topup_task = asyncio.create_task(periodic_auto_topup())
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher())
yield
@@ -163,6 +165,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
refund_sweep_task.cancel()
if routstr_fee_task is not None:
routstr_fee_task.cancel()
if invoice_watcher_task is not None:
invoice_watcher_task.cancel()
try:
tasks_to_wait = []
@@ -190,6 +194,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(refund_sweep_task)
if routstr_fee_task is not None:
tasks_to_wait.append(routstr_fee_task)
if invoice_watcher_task is not None:
tasks_to_wait.append(invoice_watcher_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
+44 -5
View File
@@ -1,13 +1,14 @@
import asyncio
import hashlib
import secrets
import time
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlmodel import select
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .core.db import ApiKey, LightningInvoice, get_session
from .core.db import ApiKey, LightningInvoice, create_session, get_session
from .core.logging import get_logger
from .core.settings import settings
from .wallet import get_wallet
@@ -159,13 +160,13 @@ async def get_invoice_status(
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
if invoice.status == "pending" and int(time.time()) > invoice.expires_at:
invoice.status = "expired"
await session.commit()
if invoice.status == "pending":
await check_invoice_payment(invoice, session)
api_key = None
if invoice.status == "paid" and invoice.purpose == "create":
if invoice.api_key_hash:
@@ -291,3 +292,41 @@ async def topup_api_key_from_invoice(
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
await session.flush()
INVOICE_WATCH_INTERVAL_SECONDS = 5
INVOICE_WATCH_BATCH_LIMIT = 100
async def periodic_invoice_watcher() -> None:
"""Background task: detect paid Lightning invoices and credit balances.
Removes the need for clients to poll the status endpoint after paying.
"""
while True:
try:
async with create_session() as session:
now = int(time.time())
result = await session.exec(
select(LightningInvoice)
.where(
LightningInvoice.status == "pending",
col(LightningInvoice.expires_at) > now,
)
.limit(INVOICE_WATCH_BATCH_LIMIT)
)
pending = result.all()
for invoice in pending:
try:
await check_invoice_payment(invoice, session)
except Exception as e:
logger.error(
"Invoice watcher failed for invoice",
extra={"invoice_id": invoice.id, "error": str(e)},
)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Invoice watcher loop error: {e}")
await asyncio.sleep(INVOICE_WATCH_INTERVAL_SECONDS)
+292 -2
View File
@@ -53,7 +53,11 @@ import {
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { AdminService, type Transaction } from '@/lib/api/services/admin';
import {
AdminService,
type Transaction,
type LightningInvoice,
} from '@/lib/api/services/admin';
import { format } from 'date-fns';
import { toast } from 'sonner';
@@ -200,6 +204,172 @@ function TransactionTable({
);
}
function LightningInvoiceTable({
invoices,
copiedId,
onCopy,
}: {
invoices: LightningInvoice[];
copiedId: string | null;
onCopy: (text: string, id: string) => void;
}) {
if (invoices.length === 0) {
return (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Zap className='h-4 w-4' />
</EmptyMedia>
<EmptyTitle>No invoices found</EmptyTitle>
<EmptyDescription>
Lightning invoices created via /lightning/invoice will show here.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
const statusBadge = (status: LightningInvoice['status']) => {
if (status === 'paid')
return (
<Badge
variant='outline'
className='border-green-500/20 bg-green-500/10 text-green-500'
>
Paid
</Badge>
);
if (status === 'expired')
return (
<Badge
variant='outline'
className='border-red-500/20 bg-red-500/10 text-red-500'
>
Expired
</Badge>
);
if (status === 'cancelled')
return (
<Badge
variant='outline'
className='border-gray-500/20 bg-gray-500/10 text-gray-500'
>
Cancelled
</Badge>
);
return (
<Badge
variant='outline'
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
>
Pending
</Badge>
);
};
return (
<ScrollArea className='h-[55svh] min-h-[420px] w-full sm:h-[600px]'>
<div className='min-w-[900px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Purpose</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>API Key</TableHead>
<TableHead>Payment Hash</TableHead>
<TableHead>Created</TableHead>
<TableHead>Paid</TableHead>
<TableHead className='text-right'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell>
<span className='capitalize'>{inv.purpose}</span>
</TableCell>
<TableCell className='font-mono'>
{inv.amount_sats} sat
</TableCell>
<TableCell>{statusBadge(inv.status)}</TableCell>
<TableCell>
{inv.api_key_hash ? (
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[120px] truncate font-mono'>
{inv.api_key_hash.slice(0, 12)}...
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() =>
onCopy(inv.api_key_hash!, inv.id + '-apikey')
}
>
{copiedId === inv.id + '-apikey' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</TableCell>
<TableCell>
<div className='flex items-center gap-1 text-xs'>
<span className='max-w-[140px] truncate font-mono'>
{inv.payment_hash.slice(0, 14)}...
</span>
<Button
variant='ghost'
size='icon'
className='h-4 w-4'
onClick={() => onCopy(inv.payment_hash, inv.id + '-hash')}
>
{copiedId === inv.id + '-hash' ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
</div>
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{format(inv.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-xs whitespace-nowrap'>
{inv.paid_at
? format(inv.paid_at * 1000, 'yyyy-MM-dd HH:mm:ss')
: '—'}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(inv.bolt11, inv.id + '-bolt11')}
title='Copy BOLT11'
>
{copiedId === inv.id + '-bolt11' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ScrollBar orientation='horizontal' />
</ScrollArea>
);
}
export default function TransactionsPage() {
const [search, setSearch] = useState('');
const [type, setType] = useState<string>('all');
@@ -231,6 +401,7 @@ export default function TransactionsPage() {
const [activeTab, setActiveTab] = useState<string>('x-cashu');
const [xcashuPage, setXcashuPage] = useState(0);
const [apikeyPage, setApikeyPage] = useState(0);
const [lightningPage, setLightningPage] = useState(0);
const typeParam = type === 'all' ? undefined : type;
const statusParam = status === 'all' ? undefined : status;
@@ -278,12 +449,37 @@ export default function TransactionsPage() {
placeholderData: keepPreviousData,
});
const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled'];
const lightningStatusParam = LIGHTNING_STATUSES.includes(status)
? status
: undefined;
const lightningQuery = useQuery({
queryKey: [
'lightning-invoices',
lightningStatusParam,
searchParam,
lightningPage,
],
queryFn: () =>
AdminService.getLightningInvoices(
lightningStatusParam,
undefined,
searchParam,
PAGE_SIZE,
lightningPage * PAGE_SIZE
),
placeholderData: keepPreviousData,
refetchInterval: 10000,
});
const handleClearFilters = () => {
setSearch('');
setType('all');
setStatus('all');
setXcashuPage(0);
setApikeyPage(0);
setLightningPage(0);
};
const copyToClipboard = (text: string, id: string) => {
@@ -337,9 +533,13 @@ export default function TransactionsPage() {
useEffect(() => {
setXcashuPage(0);
setApikeyPage(0);
setLightningPage(0);
}, [type, status, search]);
const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching;
const isRefetching =
xcashuQuery.isRefetching ||
apikeyQuery.isRefetching ||
lightningQuery.isRefetching;
const renderCardContent = (
query: typeof xcashuQuery,
@@ -417,6 +617,7 @@ export default function TransactionsPage() {
onClick={() => {
xcashuQuery.refetch();
apikeyQuery.refetch();
lightningQuery.refetch();
}}
variant='outline'
size='sm'
@@ -476,6 +677,11 @@ export default function TransactionsPage() {
<SelectItem value='pending'>Pending</SelectItem>
<SelectItem value='collected'>Collected</SelectItem>
<SelectItem value='swept'>Swept</SelectItem>
<SelectItem value='paid'>Paid (Lightning)</SelectItem>
<SelectItem value='expired'>Expired (Lightning)</SelectItem>
<SelectItem value='cancelled'>
Cancelled (Lightning)
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -516,6 +722,15 @@ export default function TransactionsPage() {
</Badge>
)}
</TabsTrigger>
<TabsTrigger value='lightning' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
Lightning
{lightningQuery.data && (
<Badge variant='secondary' className='ml-1'>
{lightningQuery.data.total}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value='x-cashu'>
@@ -553,6 +768,81 @@ export default function TransactionsPage() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value='lightning'>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Lightning Invoice History</CardTitle>
<CardDescription>
Auto-refreshing every 10s. Paid invoices credit balance
automatically.
</CardDescription>
</div>
</CardHeader>
<CardContent className='overflow-hidden'>
{lightningQuery.isLoading ? (
<div className='space-y-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton
key={`ln-loading-${index}`}
className='h-16 w-full rounded-lg'
/>
))}
</div>
) : (
<>
{(() => {
const total = lightningQuery.data?.total ?? 0;
const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) return null;
return (
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground text-xs sm:text-sm'>
{lightningPage * PAGE_SIZE + 1}
{Math.min((lightningPage + 1) * PAGE_SIZE, total)}{' '}
of {total}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
disabled={lightningPage === 0}
onClick={() =>
setLightningPage(lightningPage - 1)
}
>
<ChevronLeft className='h-4 w-4' />
<span className='hidden sm:inline'>Previous</span>
</Button>
<span className='text-xs sm:text-sm'>
{lightningPage + 1} / {totalPages}
</span>
<Button
variant='outline'
size='sm'
disabled={lightningPage >= totalPages - 1}
onClick={() =>
setLightningPage(lightningPage + 1)
}
>
<span className='hidden sm:inline'>Next</span>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
);
})()}
<LightningInvoiceTable
invoices={lightningQuery.data?.invoices ?? []}
copiedId={copiedId}
onCopy={copyToClipboard}
/>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</AppPageShell>
+38
View File
@@ -908,6 +908,25 @@ export class AdminService {
);
}
static async getLightningInvoices(
status?: string,
purpose?: string,
search?: string,
limit: number = 50,
offset: number = 0
): Promise<LightningInvoicesResponse> {
const params = new URLSearchParams();
if (status) params.append('status', status);
if (purpose) params.append('purpose', purpose);
if (search) params.append('search', search);
params.append('limit', limit.toString());
params.append('offset', offset.toString());
return await apiClient.get<LightningInvoicesResponse>(
`/admin/api/lightning-invoices?${params.toString()}`
);
}
static async createProviderAccountByType(providerType: string): Promise<{
ok: boolean;
account_data: Record<string, unknown>;
@@ -1186,3 +1205,22 @@ export interface TransactionsResponse {
transactions: Transaction[];
total: number;
}
export interface LightningInvoice {
id: string;
bolt11: string;
amount_sats: number;
description: string;
payment_hash: string;
status: 'pending' | 'paid' | 'expired' | 'cancelled';
api_key_hash: string | null;
purpose: 'create' | 'topup';
created_at: number;
expires_at: number;
paid_at: number | null;
}
export interface LightningInvoicesResponse {
invoices: LightningInvoice[];
total: number;
}