Compare commits

...
6 changed files with 346 additions and 54 deletions
+60 -35
View File
@@ -1,3 +1,4 @@
import asyncio
import json
import secrets
from datetime import datetime, timezone
@@ -865,12 +866,6 @@ async def initiate_provider_topup(
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
try:
logger.info(
f"Initiating top-up for provider {provider_id}",
@@ -884,39 +879,69 @@ async def initiate_provider_topup(
async with httpx.AsyncClient() as client:
clean_url = provider.base_url.rstrip("/")
# Proxy the request to upstream Routstr
# Use the actual API key from the database
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json={
"amount_sats": int(payload.amount),
"purpose": "topup",
"api_key": provider.api_key,
},
headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {},
request_json = {
"amount_sats": int(payload.amount),
"purpose": "topup",
"api_key": provider.api_key,
}
headers = (
{"Authorization": f"Bearer {provider.api_key}"}
if provider.api_key
else {}
)
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
else:
logger.error(f"Upstream topup request failed: {resp.text}")
# Check if it's JSON error
try:
error_detail = resp.json()
except Exception:
error_detail = resp.text
raise HTTPException(
status_code=resp.status_code, detail=error_detail
last_status_code = 500
last_error_detail: object = "Failed to create top-up invoice"
# Some upstream Routstr nodes fail the first invoice request after warm-up
# and succeed immediately on retry. Retry once here so the UI stays single-click.
for attempt in range(2):
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json=request_json,
headers=headers,
)
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
logger.error(
f"Upstream topup request failed: {resp.text}",
extra={
"provider_id": provider_id,
"attempt": attempt + 1,
"status_code": resp.status_code,
},
)
try:
last_error_detail = resp.json()
except Exception:
last_error_detail = resp.text
last_status_code = resp.status_code
if resp.status_code < 500 or attempt == 1:
break
await asyncio.sleep(0.2)
raise HTTPException(
status_code=last_status_code, detail=last_error_detail
)
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
status_code=400, detail="Could not instantiate provider"
)
topup_data = await upstream_instance.initiate_topup(payload.amount)
logger.info(
+1 -1
View File
@@ -83,7 +83,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
Balance in satoshis, or None if failed
"""
url = f"{self.base_url}/v1/balance/info"
headers = {"Authorization": f"Bearer {self.api_key}"}
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
async with httpx.AsyncClient() as client:
try:
@@ -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)
+76
View File
@@ -0,0 +1,76 @@
from types import TracebackType
from unittest.mock import Mock
import httpx
import pytest
from routstr.upstream.routstr import RoutstrUpstreamProvider
class DummyAsyncClient:
def __init__(self, response: Mock | None = None, error: Exception | None = None):
self.response = response
self.error = error
self.calls: list[dict[str, object]] = []
async def __aenter__(self) -> "DummyAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> bool:
return False
async def get(
self, url: str, headers: dict[str, str], timeout: float
) -> Mock:
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
if self.error is not None:
raise self.error
assert self.response is not None
return self.response
@pytest.mark.asyncio
async def test_get_balance_omits_auth_header_when_api_key_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = Mock()
response.json.return_value = {"balance_msats": 42000}
response.raise_for_status.return_value = None
client = DummyAsyncClient(response=response)
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="")
balance = await provider.get_balance()
assert balance == 42.0
assert client.calls == [
{
"url": "https://node.example/v1/balance/info",
"headers": {},
"timeout": 10.0,
}
]
@pytest.mark.asyncio
async def test_get_balance_returns_none_on_connect_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out"))
monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client)
provider = RoutstrUpstreamProvider(
base_url="https://node.example",
api_key="secret",
)
balance = await provider.get_balance()
assert balance is 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);
}}