Compare commits

...
5 changed files with 329 additions and 27 deletions
+40 -9
View File
@@ -1050,15 +1050,46 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
if provider.provider_type == "routstr":
import httpx
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
resp = await client.get(
f"{clean_url}/v1/balance/info",
headers=headers,
)
clean_url = provider.base_url.rstrip("/")
headers = {}
if provider.api_key:
headers["Authorization"] = f"Bearer {provider.api_key}"
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(
f"{clean_url}/v1/balance/info",
headers=headers,
)
except httpx.TimeoutException as exc:
logger.error(
"Timed out fetching Routstr provider balance",
extra={
"provider_id": provider_id,
"base_url": clean_url,
"upstream_url": f"{clean_url}/v1/balance/info",
"error": str(exc),
},
)
raise HTTPException(
status_code=504,
detail="Timed out contacting upstream Routstr provider",
) from exc
except httpx.RequestError as exc:
logger.error(
"Failed to fetch Routstr provider balance",
extra={
"provider_id": provider_id,
"base_url": clean_url,
"upstream_url": f"{clean_url}/v1/balance/info",
"error": str(exc),
},
)
raise HTTPException(
status_code=502,
detail="Failed to contact upstream Routstr provider",
) from exc
if resp.status_code == 200:
data = resp.json()
# Return balance in sats
@@ -0,0 +1,80 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
async def _create_routstr_provider() -> UpstreamProviderRow:
return UpstreamProviderRow(
provider_type="routstr",
base_url="https://upstream.example",
api_key="",
enabled=True,
)
def _admin_headers() -> dict[str, str]:
token = "test-admin-token"
admin_sessions[token] = int(
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
)
return {"Authorization": f"Bearer {token}"}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_provider_balance_timeout_returns_504(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = await _create_routstr_provider()
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
timeout_error = httpx.ConnectTimeout("Connect timeout", request=request)
with patch(
"httpx.AsyncHTTPTransport.handle_async_request",
new=AsyncMock(side_effect=timeout_error),
):
response = await integration_client.get(
f"/admin/api/upstream-providers/{provider.id}/balance",
headers=_admin_headers(),
)
assert response.status_code == 504
assert response.json()["detail"] == "Timed out contacting upstream Routstr provider"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_provider_balance_request_error_returns_502(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = await _create_routstr_provider()
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info")
request_error = httpx.ConnectError("Connection failed", request=request)
with patch(
"httpx.AsyncHTTPTransport.handle_async_request",
new=AsyncMock(side_effect=request_error),
):
response = await integration_client.get(
f"/admin/api/upstream-providers/{provider.id}/balance",
headers=_admin_headers(),
)
assert response.status_code == 502
assert response.json()["detail"] == "Failed to contact upstream Routstr provider"
@@ -3,12 +3,16 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
import time
from types import TracebackType
from typing import Any, Generator
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
@@ -678,3 +682,88 @@ async def test_no_database_changes_during_provider_operations(
assert final_diff["api_keys"]["added"] == []
assert final_diff["api_keys"]["modified"] == []
assert final_diff["api_keys"]["removed"] == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_topup_retries_transient_upstream_failure(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="routstr",
base_url="https://node.example",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
class MockResponse:
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
self.status_code = status_code
self._data = data or {}
self.text = str(self._data)
def json(self) -> dict[str, Any]:
return self._data
class MockAsyncClient:
def __init__(self) -> None:
self.calls = 0
async def __aenter__(self) -> "MockAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
async def post(
self, url: str, json: dict[str, Any], headers: dict[str, str]
) -> MockResponse:
self.calls += 1
assert url == "https://node.example/v1/balance/lightning/invoice"
assert json["amount_sats"] == 10
assert json["purpose"] == "topup"
assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test"
if self.calls == 1:
return MockResponse(500, {"detail": "warmup failure"})
return MockResponse(
200,
{
"bolt11": "lnbc1testinvoice",
"invoice_id": "invoice-123",
},
)
mock_client = MockAsyncClient()
try:
with patch("httpx.AsyncClient", return_value=mock_client):
response = await integration_client.post(
f"/admin/api/upstream-providers/{provider.id}/topup",
json={"amount": 10},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
assert data["topup_data"]["invoice_id"] == "invoice-123"
assert mock_client.calls == 2
finally:
admin_sessions.pop(admin_token, None)
+45 -12
View File
@@ -23,11 +23,15 @@ import {
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 [topupAmount, setTopupAmount] = useState('');
@@ -100,14 +104,23 @@ export function ProviderBalance({
});
const handleTopup = () => {
const amount = parseFloat(topupAmount);
const amount = Number(topupAmount);
if (isNaN(amount)) {
setTopupError('Please enter a valid amount');
if (Number.isNaN(amount)) {
setTopupError(
isRoutstr
? 'Please enter a valid amount in sats'
: 'Please enter a valid amount'
);
return;
}
if (amount < 1 || amount > 500) {
if (isRoutstr) {
if (!Number.isInteger(amount) || amount < 1) {
setTopupError('Amount must be a whole number of sats');
return;
}
} else if (amount < 1 || amount > 500) {
setTopupError('Amount must be between $1 and $500');
return;
}
@@ -153,15 +166,24 @@ export function ProviderBalance({
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = `$${balance.toFixed(2)}`;
displayValue = isRoutstr
? `${balance.toLocaleString()} sats`
: `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
displayValue = isRoutstr
? `${b.balance.toLocaleString()} sats`
: `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
const amount = Number(b.amount);
if (!Number.isNaN(amount)) {
displayValue = isRoutstr
? `${amount.toLocaleString()} sats`
: `$${amount.toFixed(2)}`;
}
}
}
@@ -193,7 +215,9 @@ export function ProviderBalance({
? 'Your account balance has been updated.'
: invoiceData
? 'Scan the QR code or copy the Lightning invoice to pay.'
: 'Enter the amount you want to add to your account balance.'}
: isRoutstr
? `Top up your balance on node ${nodeUrl || ''}`.trim()
: 'Enter the amount you want to add to your account balance.'}
</DialogDescription>
</DialogHeader>
@@ -254,20 +278,29 @@ export function ProviderBalance({
) : (
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='topup_amount'>Amount (USD)</Label>
<Label htmlFor='topup_amount'>
{isRoutstr ? 'Amount (sats)' : 'Amount (USD)'}
</Label>
<Input
id='topup_amount'
type='number'
placeholder='Enter amount (1-500)'
placeholder={
isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)'
}
value={topupAmount}
onChange={(e) => {
setTopupAmount(e.target.value);
setTopupError('');
}}
min='1'
max='500'
step='0.01'
max={isRoutstr ? undefined : '500'}
step={isRoutstr ? '1' : '0.01'}
/>
{isRoutstr && (
<p className='text-muted-foreground text-sm'>
The invoice amount will be created in sats.
</p>
)}
{topupError && (
<p className='text-destructive text-sm'>{topupError}</p>
)}
+75 -6
View File
@@ -19,11 +19,15 @@ import {
Pencil,
Trash2,
Key,
RotateCcw,
} from 'lucide-react';
import { ProviderBalance } from '@/components/provider-balance';
import { ProviderModelsPanel } from '@/components/provider-models-panel';
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
Dialog,
@@ -70,10 +74,29 @@ export function ProviderCard({
onDeleteModel,
onOverrideModel,
onUpdateApiKey,
availableMints,
}: ProviderCardProps) {
const queryClient = useQueryClient();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const hasDetails = Boolean(provider.api_version) || isExpanded;
const isRoutstr = provider.provider_type === 'routstr';
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}`);
},
});
return (
<Card>
@@ -107,11 +130,13 @@ export function ProviderCard({
<ProviderBalance
providerId={provider.id}
platformUrl={platformUrl}
isRoutstr={provider.provider_type === 'routstr'}
nodeUrl={provider.base_url}
/>
</div>
)}
{provider.provider_type === 'routstr' && (
{isRoutstr && (
<Button
variant='outline'
size='sm'
@@ -128,6 +153,25 @@ export function ProviderCard({
</Button>
)}
{isRoutstr && provider.api_key && (
<Button
variant='outline'
size='sm'
onClick={() => refundMutation.mutate()}
disabled={refundMutation.isPending}
className='justify-center gap-1.5 text-orange-600 hover:text-orange-700 dark:text-orange-400'
title='Refund balance to local wallet'
>
<RotateCcw
className={cn(
'h-4 w-4',
refundMutation.isPending && 'animate-spin'
)}
/>
<span>Refund</span>
</Button>
)}
<Button
variant='outline'
size='sm'
@@ -171,16 +215,41 @@ export function ProviderCard({
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Generate New Key</DialogTitle>
<DialogTitle>
{provider.api_key
? 'Create New Key on Upstream Node'
: 'Create API Key'}
</DialogTitle>
<DialogDescription>
Create a new API key for provider{' '}
<span className='font-medium'>{provider.provider_type}</span>.
{provider.api_key
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
</DialogDescription>
</DialogHeader>
<div className='py-4'>
<RoutstrCreateKeySection
baseUrl={provider.base_url || ''}
onApiKeyCreated={(newApiKey) => {
onApiKeyCreated={async (newApiKey) => {
if (provider.api_key) {
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'}`
);
}
}
onUpdateApiKey(newApiKey);
setIsKeyModalOpen(false);
}}