diff --git a/ui/components/providers/ProviderBalance.tsx b/ui/components/providers/ProviderBalance.tsx
new file mode 100644
index 00000000..2ebb3966
--- /dev/null
+++ b/ui/components/providers/ProviderBalance.tsx
@@ -0,0 +1,172 @@
+'use client';
+
+import { useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { AdminService } from '@/lib/api/services/admin';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Separator } from '@/components/ui/separator';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { SimpleLightningTopup } from './SimpleLightningTopup';
+import { SimpleCashuTopup } from './SimpleCashuTopup';
+
+interface ProviderBalanceProps {
+ providerId: number;
+ platformUrl?: string | null;
+ isRoutstr?: boolean;
+ nodeUrl?: string;
+}
+
+export function ProviderBalance({
+ providerId,
+ platformUrl,
+ isRoutstr = false,
+ nodeUrl,
+}: ProviderBalanceProps) {
+ const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const queryClient = useQueryClient();
+
+ const {
+ data: balanceData,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['provider-balance', providerId],
+ queryFn: () => AdminService.getProviderBalance(providerId),
+ refetchInterval: 30000,
+ refetchOnWindowFocus: true,
+ retry: 1,
+ });
+
+ const handleTopUpClick = () => {
+ if (
+ platformUrl &&
+ (platformUrl.includes('openrouter.ai') ||
+ platformUrl.includes('openai.com'))
+ ) {
+ window.open(platformUrl, '_blank');
+ return;
+ }
+
+ setIsTopupDialogOpen(true);
+ };
+
+ const handleCloseDialog = () => {
+ setIsTopupDialogOpen(false);
+ queryClient.invalidateQueries({
+ queryKey: ['provider-balance', providerId],
+ });
+ };
+
+ if (isLoading) {
+ return
;
+ }
+
+ if (
+ error ||
+ !balanceData?.ok ||
+ balanceData.balance_data === undefined ||
+ balanceData.balance_data === null
+ ) {
+ return null;
+ }
+
+ const balance = balanceData.balance_data;
+ let displayValue = 'N/A';
+
+ if (typeof balance === 'number') {
+ displayValue = isRoutstr
+ ? `${balance.toLocaleString()} sats`
+ : `$${balance.toFixed(2)}`;
+ } else if (balance && typeof balance === 'object') {
+ const b = balance as Record
;
+ if (typeof b.balance === 'number') {
+ displayValue = `$${b.balance.toFixed(2)}`;
+ } else if (typeof b.balance === 'string') {
+ displayValue = b.balance;
+ } else if (b.amount !== undefined) {
+ displayValue = `$${Number(b.amount).toFixed(2)}`;
+ }
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/ui/components/providers/RoutstrCreateKeySection.tsx b/ui/components/providers/RoutstrCreateKeySection.tsx
new file mode 100644
index 00000000..0d9de579
--- /dev/null
+++ b/ui/components/providers/RoutstrCreateKeySection.tsx
@@ -0,0 +1,295 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import Image from 'next/image';
+import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
+import { toast } from 'sonner';
+import QRCode from 'qrcode';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+
+interface RoutstrCreateKeySectionProps {
+ baseUrl: string;
+ onApiKeyCreated: (apiKey: string) => void;
+}
+
+async function generateQR(text: string): Promise {
+ try {
+ return await QRCode.toDataURL(text, {
+ type: 'image/png',
+ width: 200,
+ margin: 1,
+ color: { dark: '#000000', light: '#FFFFFF' },
+ });
+ } catch {
+ return '';
+ }
+}
+
+export function RoutstrCreateKeySection({
+ baseUrl,
+ onApiKeyCreated,
+}: RoutstrCreateKeySectionProps) {
+ // Lightning state
+ const [lnAmount, setLnAmount] = useState('');
+ const [lnInvoice, setLnInvoice] = useState<{
+ bolt11: string;
+ invoice_id: string;
+ } | null>(null);
+ const [lnQrCode, setLnQrCode] = useState('');
+ const [isCreatingLn, setIsCreatingLn] = useState(false);
+ const [isWaitingLn, setIsWaitingLn] = useState(false);
+
+ // Cashu state
+ const [cashuToken, setCashuToken] = useState('');
+ const [isCreatingCashu, setIsCreatingCashu] = useState(false);
+
+ if (!baseUrl) {
+ return (
+
+
+ Enter the upstream node Base URL above to enable key creation.
+
+
+ );
+ }
+
+ const cleanUrl = baseUrl.replace(/\/+$/, '');
+
+ const handleCopy = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success('Copied to clipboard');
+ } catch {
+ toast.error('Failed to copy');
+ }
+ };
+
+ const pollInvoiceStatus = (invoiceId: string) => {
+ let attempts = 0;
+ const maxAttempts = 60;
+
+ const poll = async () => {
+ try {
+ const resp = await fetch(
+ `${cleanUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
+ );
+ if (!resp.ok) throw new Error('Failed to check status');
+
+ const status = await resp.json();
+
+ if (status.status === 'paid' && status.api_key) {
+ onApiKeyCreated(status.api_key);
+ setLnInvoice(null);
+ setLnQrCode('');
+ setIsWaitingLn(false);
+ setLnAmount('');
+ toast.success('Payment received! API key created.');
+ return;
+ }
+
+ if (status.status === 'expired' || status.status === 'cancelled') {
+ toast.error('Invoice expired or cancelled');
+ setIsWaitingLn(false);
+ return;
+ }
+
+ attempts++;
+ if (attempts < maxAttempts) {
+ setTimeout(poll, 5000);
+ } else {
+ toast.error('Payment timeout');
+ setIsWaitingLn(false);
+ }
+ } catch {
+ attempts++;
+ if (attempts < maxAttempts) {
+ setTimeout(poll, 5000);
+ } else {
+ setIsWaitingLn(false);
+ }
+ }
+ };
+
+ poll();
+ };
+
+ const handleCreateLightning = async () => {
+ const amount = parseInt(lnAmount);
+ if (!amount || amount <= 0) {
+ toast.error('Enter a valid amount in sats');
+ return;
+ }
+
+ setIsCreatingLn(true);
+ try {
+ const resp = await fetch(`${cleanUrl}/v1/balance/lightning/invoice`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ amount_sats: amount, purpose: 'create' }),
+ });
+
+ if (!resp.ok) {
+ const errorText = await resp.text();
+ throw new Error(errorText || 'Failed to create invoice');
+ }
+
+ const data = await resp.json();
+ setLnInvoice({ bolt11: data.bolt11, invoice_id: data.invoice_id });
+
+ const qr = await generateQR(data.bolt11);
+ setLnQrCode(qr);
+ setIsWaitingLn(true);
+
+ pollInvoiceStatus(data.invoice_id);
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : 'Failed to create invoice');
+ } finally {
+ setIsCreatingLn(false);
+ }
+ };
+
+ const handleCreateCashu = async () => {
+ if (!cashuToken.trim()) {
+ toast.error('Paste a Cashu token');
+ return;
+ }
+
+ setIsCreatingCashu(true);
+ try {
+ const params = new URLSearchParams({
+ initial_balance_token: cashuToken.trim(),
+ });
+ const resp = await fetch(
+ `${cleanUrl}/v1/balance/create?${params.toString()}`,
+ { method: 'GET', headers: { 'Content-Type': 'application/json' } }
+ );
+
+ if (!resp.ok) {
+ const errorText = await resp.text();
+ throw new Error(errorText || 'Failed to create API key');
+ }
+
+ const data = await resp.json();
+ onApiKeyCreated(data.api_key);
+ setCashuToken('');
+ toast.success('API key created');
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : 'Failed to create key');
+ } finally {
+ setIsCreatingCashu(false);
+ }
+ };
+
+ return (
+
+
+
+
+ External Node
+
+
+
+
+ Create an API key on the upstream Routstr node by paying with Lightning
+ or Cashu.
+
+
+
+
+
+
+ Lightning
+
+
+
+ Cashu
+
+
+
+
+
+ setLnAmount(e.target.value)}
+ className='h-9'
+ disabled={isWaitingLn}
+ />
+
+
+
+ {lnInvoice && (
+
+
+ Pay this invoice to create your key
+
+
+ {lnQrCode && (
+
+
+
+ )}
+
+ {lnInvoice.bolt11}
+
+ {isWaitingLn && (
+
+
+ Waiting for payment...
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/ui/components/providers/RoutstrNodeSettings.tsx b/ui/components/providers/RoutstrNodeSettings.tsx
new file mode 100644
index 00000000..18ace0dc
--- /dev/null
+++ b/ui/components/providers/RoutstrNodeSettings.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+
+interface ProviderSettings {
+ topup_mint_url?: string;
+ auto_topup?: boolean;
+ topup_threshold?: number;
+ topup_amount_limit?: number;
+ refund_on_expiry?: boolean;
+ [key: string]: unknown;
+}
+
+interface RoutstrNodeSettingsProps {
+ settings: ProviderSettings;
+ onSettingsChange: (settings: ProviderSettings) => void;
+ availableMints: string[];
+ idPrefix?: string;
+}
+
+export function RoutstrNodeSettings({
+ settings,
+ onSettingsChange,
+ availableMints,
+ idPrefix = '',
+}: RoutstrNodeSettingsProps) {
+ const prefix = idPrefix ? `${idPrefix}_` : '';
+
+ const update = (patch: Partial) => {
+ onSettingsChange({ ...settings, ...patch });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ The token for top-up will be created from this mint.
+
+
+
+
+
+ update({ auto_topup: checked })}
+ />
+
+
+ {settings.auto_topup && (
+
+ )}
+
+
+ );
+}
diff --git a/ui/components/providers/RoutstrProviderCard.tsx b/ui/components/providers/RoutstrProviderCard.tsx
new file mode 100644
index 00000000..5fd29374
--- /dev/null
+++ b/ui/components/providers/RoutstrProviderCard.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Database,
+ Pencil,
+ Trash2,
+ ChevronDown,
+ ChevronUp,
+ RotateCcw,
+ AlertTriangle,
+ KeyRound,
+} from 'lucide-react';
+import {
+ AdminService,
+ UpstreamProvider,
+ UpdateUpstreamProvider,
+} from '@/lib/api/services/admin';
+import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
+import { RoutstrCreateKeySection } from './RoutstrCreateKeySection';
+import { toast } from 'sonner';
+
+interface RoutstrProviderCardProps {
+ provider: UpstreamProvider;
+ expanded: boolean;
+ onToggleExpand: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onUpdateKey?: () => void;
+ balanceComponent: React.ReactNode;
+ children?: React.ReactNode;
+}
+
+export function RoutstrProviderCard({
+ provider,
+ expanded,
+ onToggleExpand,
+ onEdit,
+ onDelete,
+ balanceComponent,
+ children,
+}: RoutstrProviderCardProps) {
+ const queryClient = useQueryClient();
+ const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
+
+ const hasMint = !!provider.provider_settings?.topup_mint_url;
+ const hasApiKey = !!provider.api_key;
+
+ const refundMutation = useMutation({
+ mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
+ onSuccess: (data) => {
+ if (data.ok) {
+ toast.success('Refund successful', {
+ description: data.message,
+ });
+ queryClient.invalidateQueries({
+ queryKey: ['provider-balance', provider.id],
+ });
+ queryClient.invalidateQueries({ queryKey: ['balances'] });
+ } else {
+ toast.error('Refund failed', {
+ description: data.message,
+ });
+ }
+ },
+ onError: (error: Error) => {
+ toast.error(`Refund error: ${error.message}`);
+ },
+ });
+
+ const updateKeyMutation = useMutation({
+ mutationFn: (data: { id: number; data: UpdateUpstreamProvider }) =>
+ AdminService.updateUpstreamProvider(data.id, data.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
+ queryClient.invalidateQueries({
+ queryKey: ['provider-balance', provider.id],
+ });
+ setIsKeyDialogOpen(false);
+ toast.success('API key saved to provider');
+ },
+ onError: (error: Error) => {
+ toast.error(`Failed to save key: ${error.message}`);
+ },
+ });
+
+ const handleKeyCreated = async (newApiKey: string) => {
+ if (hasApiKey) {
+ try {
+ const result = await RoutstrProviderService.refundBalance(provider.id);
+ if (result.ok) {
+ toast.success('Old key refunded', {
+ description: result.message,
+ });
+ } else {
+ toast.warning('Refund skipped', {
+ description: result.message,
+ });
+ }
+ } catch (error) {
+ toast.warning(
+ `Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
+ );
+ }
+ }
+ updateKeyMutation.mutate({
+ id: provider.id,
+ data: { api_key: newApiKey },
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
Routstr Node
+
+ {provider.enabled ? 'Enabled' : 'Disabled'}
+
+ {!hasApiKey && (
+
+
+ No API Key
+
+ )}
+ {!hasMint && (
+
+
+ Top-up Disabled: No Mint Selected
+
+ )}
+
+
+ {provider.base_url}
+
+
+
+ {hasApiKey && (
+
{balanceComponent}
+ )}
+
+
+
+ {hasApiKey && (
+
+ )}
+
+
+
+
+
+
+
+ {children}
+
+
+
+ >
+ );
+}
diff --git a/ui/components/providers/SimpleCashuTopup.tsx b/ui/components/providers/SimpleCashuTopup.tsx
new file mode 100644
index 00000000..d988f2e7
--- /dev/null
+++ b/ui/components/providers/SimpleCashuTopup.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import { type JSX, useState } from 'react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { AdminService } from '@/lib/api/services/admin';
+
+interface SimpleCashuTopupProps {
+ providerId: number;
+ baseUrl: string;
+ onSuccess?: () => void;
+}
+
+export function SimpleCashuTopup({
+ providerId,
+ onSuccess,
+}: SimpleCashuTopupProps): JSX.Element {
+ const [token, setToken] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleTopup = async () => {
+ if (!token.trim()) {
+ toast.error('Enter a Cashu token');
+ return;
+ }
+ setIsLoading(true);
+ try {
+ // Use the backend to proxy the token topup
+ // This is safer as the backend has the actual API key
+ const response = await AdminService.topupProviderWithToken(
+ providerId,
+ token.trim()
+ );
+ if (!response.ok) throw new Error(response.message || 'Top-up failed');
+
+ toast.success('Token redeemed successfully!');
+ setToken('');
+ onSuccess?.();
+ } catch (e) {
+ const error = e as Error;
+ toast.error(error.message || 'Top-up failed');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/components/providers/SimpleLightningTopup.tsx b/ui/components/providers/SimpleLightningTopup.tsx
new file mode 100644
index 00000000..f6f0ada3
--- /dev/null
+++ b/ui/components/providers/SimpleLightningTopup.tsx
@@ -0,0 +1,188 @@
+'use client';
+
+import { type JSX, useCallback, useState } from 'react';
+import Image from 'next/image';
+import { Copy, Loader2 } from 'lucide-react';
+import { toast } from 'sonner';
+import QRCode from 'qrcode';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { AdminService } from '@/lib/api/services/admin';
+
+async function generateQRCodeSVG(text: string): Promise {
+ try {
+ return await QRCode.toDataURL(text, {
+ type: 'image/png',
+ width: 400,
+ margin: 1,
+ color: {
+ dark: '#000000',
+ light: '#FFFFFF',
+ },
+ });
+ } catch (error) {
+ console.error('Failed to generate QR code:', error);
+ return '';
+ }
+}
+
+interface SimpleLightningTopupProps {
+ providerId: number;
+ baseUrl: string;
+ onSuccess?: () => void;
+}
+
+export function SimpleLightningTopup({
+ providerId,
+ onSuccess,
+}: SimpleLightningTopupProps): JSX.Element {
+ const [amount, setAmount] = useState('');
+ const [isCreating, setIsCreating] = useState(false);
+ const [invoice, setInvoice] = useState<{
+ bolt11: string;
+ invoice_id: string;
+ } | null>(null);
+ const [qrCode, setQrCode] = useState('');
+ const [isWaiting, setIsWaiting] = useState(false);
+
+ const handleCopy = (text: string) => {
+ navigator.clipboard.writeText(text);
+ toast.success('Copied to clipboard');
+ };
+
+ const pollStatus = useCallback(
+ async (invoiceId: string) => {
+ const maxAttempts = 60; // 5 minutes with 5 second intervals
+ let attempts = 0;
+
+ const poll = async () => {
+ try {
+ const response = await AdminService.checkTopupStatus(
+ providerId,
+ invoiceId
+ );
+
+ if (response.paid) {
+ toast.success('Payment received!');
+ setInvoice(null);
+ setQrCode('');
+ setIsWaiting(false);
+ onSuccess?.();
+ return;
+ }
+
+ attempts++;
+ if (attempts < maxAttempts) {
+ setTimeout(poll, 5000);
+ } else {
+ toast.error('Payment timeout - please check manually');
+ setIsWaiting(false);
+ }
+ } catch (e) {
+ console.error('Failed to poll topup status:', e);
+ attempts++;
+ if (attempts < maxAttempts) {
+ setTimeout(poll, 5000);
+ } else {
+ toast.error('Failed to check payment status');
+ setIsWaiting(false);
+ }
+ }
+ };
+ poll();
+ },
+ [providerId, onSuccess]
+ );
+
+ const handleCreate = async () => {
+ const amt = parseInt(amount);
+ if (!amt) {
+ toast.error('Enter a valid amount');
+ return;
+ }
+ setIsCreating(true);
+ try {
+ const response = await AdminService.initiateProviderTopup(
+ providerId,
+ amt
+ );
+ if (!response.ok || !response.topup_data)
+ throw new Error('Failed to create invoice');
+
+ const bolt11 = response.topup_data.payment_request as string;
+ setInvoice({
+ bolt11,
+ invoice_id: response.topup_data.invoice_id as string,
+ });
+
+ const qr = await generateQRCodeSVG(bolt11);
+ setQrCode(qr);
+
+ setIsWaiting(true);
+ pollStatus(response.topup_data.invoice_id as string);
+ } catch (e) {
+ const error = e as Error;
+ toast.error(error.message || 'Failed to request invoice from backend');
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+
+ setAmount(e.target.value)}
+ className='h-9'
+ />
+
+
+
+ {invoice && (
+
+
+ Invoice Generated
+
+
+ {qrCode && (
+
+
+
+ )}
+
+ {invoice.bolt11}
+
+ {isWaiting && (
+
+
+ Waiting for payment...
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts
index 790143c8..dac7fab8 100644
--- a/ui/lib/api/services/admin.ts
+++ b/ui/lib/api/services/admin.ts
@@ -20,6 +20,7 @@ export const UpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean(),
provider_fee: z.number().optional(),
+ provider_settings: z.record(z.any()).nullable().optional(),
});
export const CreateUpstreamProviderSchema = z.object({
@@ -29,6 +30,7 @@ export const CreateUpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean().default(true),
provider_fee: z.number().optional(),
+ provider_settings: z.record(z.any()).nullable().optional(),
});
export const UpdateUpstreamProviderSchema = z.object({
@@ -38,6 +40,7 @@ export const UpdateUpstreamProviderSchema = z.object({
api_version: z.string().nullable().optional(),
enabled: z.boolean().optional(),
provider_fee: z.number().optional(),
+ provider_settings: z.record(z.any()).nullable().optional(),
});
export const AdminModelPricingSchema = z.object({
@@ -935,9 +938,17 @@ export class AdminService {
ok: boolean;
topup_data: Record;
message: string;
- }>(`/admin/api/upstream-providers/${providerId}/topup`, {
- amount: amount,
- });
+ }>(`/admin/api/upstream-providers/${providerId}/topup`, { amount });
+ }
+
+ static async topupProviderWithToken(
+ providerId: number,
+ token: string
+ ): Promise<{ ok: boolean; message?: string }> {
+ return await apiClient.post<{ ok: boolean; message?: string }>(
+ `/admin/api/upstream-providers/${providerId}/topup-token`,
+ { token }
+ );
}
static async checkTopupStatus(
diff --git a/ui/lib/api/services/routstr-provider.ts b/ui/lib/api/services/routstr-provider.ts
new file mode 100644
index 00000000..48ba92b0
--- /dev/null
+++ b/ui/lib/api/services/routstr-provider.ts
@@ -0,0 +1,13 @@
+import { apiClient } from '../client';
+
+export class RoutstrProviderService {
+ static async refundBalance(
+ providerId: number
+ ): Promise<{ ok: boolean; message: string; refund_id?: string }> {
+ return await apiClient.post<{
+ ok: boolean;
+ message: string;
+ refund_id?: string;
+ }>(`/admin/api/upstream-providers/${providerId}/routstr/refund`, {});
+ }
+}