mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 15:26:14 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d48b36be8 | ||
|
|
8b3fdaa545 | ||
|
|
b43d57df23 | ||
|
|
b9a5a9276e | ||
|
|
a79fdf7212 | ||
|
|
9560050946 | ||
|
|
e31b45fa9e | ||
|
|
1ed8b29d64 | ||
|
|
93a368b1a2 | ||
|
|
d3dd346853 | ||
|
|
0198569a9a | ||
|
|
7e648cb5c2 | ||
|
|
deb75624f3 |
+60
-35
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user