From deb75624f33d215c58965a8cbe48741fcd30c00b Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 17:36:52 +0800 Subject: [PATCH 01/14] fix: show Routstr provider balances in sats --- ui/components/provider-balance.tsx | 23 +++++++++++++++++++---- ui/components/provider-card.tsx | 2 ++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index b6460d97..d36041c5 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -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(''); @@ -153,15 +157,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; 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 +206,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.'} diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index d239547e..9c0c99bd 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -107,6 +107,8 @@ export function ProviderCard({ )} From 7e648cb5c2578a0b188bb48168d61f9d1d9bfdf9 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 17:46:57 +0800 Subject: [PATCH 02/14] fix: use sats for Routstr top-up amounts --- ui/components/provider-balance.tsx | 36 +++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index d36041c5..f2da5206 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -104,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; } @@ -269,20 +278,31 @@ export function ProviderBalance({ ) : (
- + { setTopupAmount(e.target.value); setTopupError(''); }} min='1' - max='500' - step='0.01' + max={isRoutstr ? undefined : '500'} + step={isRoutstr ? '1' : '0.01'} /> + {isRoutstr && ( +

+ The invoice amount will be created in sats. +

+ )} {topupError && (

{topupError}

)} From 0198569a9adc8dda6d13d70fa5ade953986388f7 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:17:19 +0800 Subject: [PATCH 03/14] fix: retry transient Routstr top-up invoice failures --- routstr/core/admin.py | 129 ++++++++++-------- tests/integration/test_provider_management.py | 83 +++++++++++ 2 files changed, 153 insertions(+), 59 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..e15bcce8 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -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( @@ -1021,30 +1046,6 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") - # For Routstr providers, proxy the balance check - 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, - ) - if resp.status_code == 200: - data = resp.json() - # Return balance in sats - balance = data.get("balance", 0) - if isinstance(balance, (int, float)): - return {"ok": True, "balance_data": balance // 1000} - return {"ok": True, "balance_data": balance} - else: - logger.error(f"Failed to fetch Routstr balance: {resp.text}") - return {"ok": False, "balance_data": None} - upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1053,6 +1054,16 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: try: balance_data = await upstream_instance.get_balance() + if balance_data is None: + logger.warning( + "Balance check returned no data", + extra={ + "provider_id": provider_id, + "provider_type": provider.provider_type, + "base_url": provider.base_url, + }, + ) + return {"ok": False, "balance_data": None} return {"ok": True, "balance_data": balance_data} except NotImplementedError as e: raise HTTPException( diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index b47ad848..b9d287f6 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -3,12 +3,15 @@ Integration tests for provider management functionality. Tests GET /v1/providers/ endpoint for listing and managing providers. """ +import time 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 +681,83 @@ 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): + self.calls = 0 + + async def __aenter__(self) -> "MockAsyncClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> 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) From d3dd3468530ff712a5d7c25cd8577083fffda44f Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:20:08 +0800 Subject: [PATCH 04/14] fix: remove unrelated balance endpoint changes --- routstr/core/admin.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index e15bcce8..572b035c 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1046,6 +1046,30 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if not provider: raise HTTPException(status_code=404, detail="Provider not found") + # For Routstr providers, proxy the balance check + 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, + ) + if resp.status_code == 200: + data = resp.json() + # Return balance in sats + balance = data.get("balance", 0) + if isinstance(balance, (int, float)): + return {"ok": True, "balance_data": balance // 1000} + return {"ok": True, "balance_data": balance} + else: + logger.error(f"Failed to fetch Routstr balance: {resp.text}") + return {"ok": False, "balance_data": None} + upstream_instance = _instantiate_provider(provider) if not upstream_instance: raise HTTPException( @@ -1054,16 +1078,6 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: try: balance_data = await upstream_instance.get_balance() - if balance_data is None: - logger.warning( - "Balance check returned no data", - extra={ - "provider_id": provider_id, - "provider_type": provider.provider_type, - "base_url": provider.base_url, - }, - ) - return {"ok": False, "balance_data": None} return {"ok": True, "balance_data": balance_data} except NotImplementedError as e: raise HTTPException( From 93a368b1a2189faf2b40dbade8fec7a543d716b5 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:39:06 +0800 Subject: [PATCH 05/14] fix(admin): handle routstr balance timeouts --- routstr/core/admin.py | 95 ++++++++++++++++++----------- routstr/upstream/routstr.py | 2 +- tests/unit/test_upstream_routstr.py | 70 +++++++++++++++++++++ 3 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 tests/unit/test_upstream_routstr.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..572b035c 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -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( diff --git a/routstr/upstream/routstr.py b/routstr/upstream/routstr.py index abf82a33..ab6dd0bd 100644 --- a/routstr/upstream/routstr.py +++ b/routstr/upstream/routstr.py @@ -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: diff --git a/tests/unit/test_upstream_routstr.py b/tests/unit/test_upstream_routstr.py new file mode 100644 index 00000000..d544fb2e --- /dev/null +++ b/tests/unit/test_upstream_routstr.py @@ -0,0 +1,70 @@ +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, exc, tb) -> 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 From 59f8d31719ad9793b8ed071eafd5149d494b0015 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:45:18 +0800 Subject: [PATCH 06/14] Handle Routstr admin balance timeouts --- routstr/core/admin.py | 49 +++++++++--- .../test_admin_provider_balance.py | 79 +++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/integration/test_admin_provider_balance.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..81ac8309 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1025,15 +1025,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 diff --git a/tests/integration/test_admin_provider_balance.py b/tests/integration/test_admin_provider_balance.py new file mode 100644 index 00000000..4167e68e --- /dev/null +++ b/tests/integration/test_admin_provider_balance.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +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, +) -> 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, +) -> 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" From 1ed8b29d64aebeb26f615755bbea6ad3e4da0c24 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 07/14] style: format provider balance placeholder --- ui/components/provider-balance.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/components/provider-balance.tsx b/ui/components/provider-balance.tsx index f2da5206..6ba57659 100644 --- a/ui/components/provider-balance.tsx +++ b/ui/components/provider-balance.tsx @@ -285,9 +285,7 @@ export function ProviderBalance({ id='topup_amount' type='number' placeholder={ - isRoutstr - ? 'Enter amount in sats' - : 'Enter amount (1-500)' + isRoutstr ? 'Enter amount in sats' : 'Enter amount (1-500)' } value={topupAmount} onChange={(e) => { From e31b45fa9e7b24b9c646470759a4eecb0a6a2e10 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 08/14] test: type dummy async client exit hook --- tests/unit/test_upstream_routstr.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_upstream_routstr.py b/tests/unit/test_upstream_routstr.py index d544fb2e..8cb2427d 100644 --- a/tests/unit/test_upstream_routstr.py +++ b/tests/unit/test_upstream_routstr.py @@ -1,3 +1,4 @@ +from types import TracebackType from unittest.mock import Mock import httpx @@ -15,7 +16,12 @@ class DummyAsyncClient: async def __aenter__(self) -> "DummyAsyncClient": return self - async def __aexit__(self, exc_type, exc, tb) -> bool: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool: return False async def get( From dd88e9b172b081d60defc0d778e265c39c701495 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 09/14] test: annotate admin balance integration session --- tests/integration/test_admin_provider_balance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_admin_provider_balance.py b/tests/integration/test_admin_provider_balance.py index 4167e68e..c5e94fab 100644 --- a/tests/integration/test_admin_provider_balance.py +++ b/tests/integration/test_admin_provider_balance.py @@ -3,6 +3,7 @@ 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 @@ -29,7 +30,7 @@ def _admin_headers() -> dict[str, str]: @pytest.mark.asyncio async def test_admin_routstr_provider_balance_timeout_returns_504( integration_client: httpx.AsyncClient, - integration_session, + integration_session: AsyncSession, ) -> None: provider = await _create_routstr_provider() integration_session.add(provider) @@ -56,7 +57,7 @@ async def test_admin_routstr_provider_balance_timeout_returns_504( @pytest.mark.asyncio async def test_admin_routstr_provider_balance_request_error_returns_502( integration_client: httpx.AsyncClient, - integration_session, + integration_session: AsyncSession, ) -> None: provider = await _create_routstr_provider() integration_session.add(provider) From 9560050946efd1309089aee53e12a7e0d50f7dfd Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 19:08:45 +0800 Subject: [PATCH 10/14] test: type routstr topup async client mock --- tests/integration/test_provider_management.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py index b9d287f6..b7db0c6c 100644 --- a/tests/integration/test_provider_management.py +++ b/tests/integration/test_provider_management.py @@ -4,6 +4,7 @@ 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 @@ -714,13 +715,18 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure( return self._data class MockAsyncClient: - def __init__(self): + def __init__(self) -> None: self.calls = 0 async def __aenter__(self) -> "MockAsyncClient": return self - async def __aexit__(self, exc_type, exc, tb) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: return None async def post( From a79fdf7212308cee74adbfa43c42640359d6de98 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 13 Mar 2026 21:09:51 +0100 Subject: [PATCH 11/14] clean up --- ui/components/provider-card.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index 9c0c99bd..480d4d1c 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -70,7 +70,6 @@ export function ProviderCard({ onDeleteModel, onOverrideModel, onUpdateApiKey, - availableMints, }: ProviderCardProps) { const [isKeyModalOpen, setIsKeyModalOpen] = useState(false); const hasDetails = Boolean(provider.api_version) || isExpanded; From 7d48b36be8c9626820157e7ecc407630a5597436 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 13 Mar 2026 22:23:50 +0100 Subject: [PATCH 12/14] add missing refund button --- ui/components/provider-card.tsx | 78 ++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index 480d4d1c..b48dc64b 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -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, @@ -71,8 +75,28 @@ export function ProviderCard({ onOverrideModel, onUpdateApiKey, }: 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 ( @@ -112,7 +136,7 @@ export function ProviderCard({
)} - {provider.provider_type === 'routstr' && ( + {isRoutstr && ( + )} +