From efb57196799d69ea311b801245b68afb67d6d80b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 17 May 2026 14:39:16 +0200 Subject: [PATCH 01/12] add provider field to response --- routstr/upstream/base.py | 104 ++++++++++++++----- tests/unit/test_provider_field_injection.py | 105 ++++++++++++++++++++ 2 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 tests/unit/test_provider_field_injection.py diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 03355445..0823ea24 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -197,6 +197,22 @@ class BaseUpstreamProvider: except (TypeError, ValueError): pass + def _apply_provider_field(self, response_json: object) -> None: + """Stamp the routstr ``provider`` field onto an upstream response payload. + + Format is ``":"`` when the upstream + already reported its own provider (e.g. OpenRouter returns + ``"provider": "Fireworks"``), otherwise just ``""`` + for direct upstreams. + """ + if not isinstance(response_json, dict): + return + existing = response_json.get("provider") + if isinstance(existing, str) and existing.strip(): + response_json["provider"] = f"{self.provider_type}:{existing.strip()}" + else: + response_json["provider"] = self.provider_type + def inject_cost_metadata( self, response_json: dict, @@ -204,6 +220,7 @@ class BaseUpstreamProvider: key: ApiKey, ) -> None: """Unifies the injection of cost and usage metadata across all completion types.""" + self._apply_provider_field(response_json) if isinstance(cost_data, dict): total_msats = cost_data.get("total_msats", 0) total_usd = cost_data.get("total_usd", 0.0) @@ -723,6 +740,7 @@ class BaseUpstreamProvider: ): obj = json.loads(part) if isinstance(obj, dict): + self._apply_provider_field(obj) if obj.get("model"): last_model_seen = str(obj.get("model")) if requested_model: @@ -889,6 +907,7 @@ class BaseUpstreamProvider: try: content = await response.aread() response_json = json.loads(content) + self._apply_provider_field(response_json) logger.debug( "Parsed response JSON", @@ -1068,6 +1087,7 @@ class BaseUpstreamProvider: try: obj = json.loads(part) if isinstance(obj, dict): + self._apply_provider_field(obj) if obj.get("model"): last_model_seen = str(obj.get("model")) if requested_model: @@ -1261,6 +1281,7 @@ class BaseUpstreamProvider: try: content = await response.aread() response_json = json.loads(content) + self._apply_provider_field(response_json) logger.debug( "Parsed Responses API response JSON", @@ -1499,6 +1520,11 @@ class BaseUpstreamProvider: if msg and msg.get("model"): last_model_seen = str(msg.get("model")) + provider_added = ( + "provider" not in data + ) + self._apply_provider_field(data) + if requested_model: # Apply requested_model override model_updated = False @@ -1509,9 +1535,12 @@ class BaseUpstreamProvider: data["model"] = requested_model model_updated = True - if model_updated: + if model_updated or provider_added: line = "data: " + json.dumps(data) changed = True + elif provider_added: + line = "data: " + json.dumps(data) + changed = True if usage := msg.get("usage"): input_tokens += usage.get("input_tokens", 0) @@ -1833,6 +1862,7 @@ class BaseUpstreamProvider: ) response_json = messages_dispatch.coerce_litellm_payload(result) + self._apply_provider_field(response_json) if requested_model and "model" in response_json: response_json["model"] = requested_model @@ -3145,18 +3175,29 @@ class BaseUpstreamProvider: }, ) - if cost_data: - for i, line in enumerate(lines): - if line.startswith("data: "): - try: - data_json = json.loads(line[6:]) - if "usage" in data_json and data_json["usage"]: - data_json["usage"]["cost_sats"] = ( - cost_data.total_msats // 1000 - ) - lines[i] = "data: " + json.dumps(data_json) - except json.JSONDecodeError: - pass + for i, line in enumerate(lines): + if line.startswith("data: "): + try: + data_json = json.loads(line[6:]) + if not isinstance(data_json, dict): + continue + changed = False + if "provider" not in data_json: + self._apply_provider_field(data_json) + changed = True + if ( + cost_data + and "usage" in data_json + and data_json["usage"] + ): + data_json["usage"]["cost_sats"] = ( + cost_data.total_msats // 1000 + ) + changed = True + if changed: + lines[i] = "data: " + json.dumps(data_json) + except json.JSONDecodeError: + pass async def generate() -> AsyncGenerator[bytes, None]: for line in lines: @@ -3200,6 +3241,7 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) + self._apply_provider_field(response_json) cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model) if cost_data and "usage" in response_json: @@ -4121,18 +4163,29 @@ class BaseUpstreamProvider: }, ) - if cost_data: - for i, line in enumerate(lines): - if line.startswith("data: "): - try: - data_json = json.loads(line[6:]) - if "usage" in data_json and data_json["usage"]: - data_json["usage"]["cost_sats"] = ( - cost_data.total_msats // 1000 - ) - lines[i] = "data: " + json.dumps(data_json) - except json.JSONDecodeError: - pass + for i, line in enumerate(lines): + if line.startswith("data: "): + try: + data_json = json.loads(line[6:]) + if not isinstance(data_json, dict): + continue + changed = False + if "provider" not in data_json: + self._apply_provider_field(data_json) + changed = True + if ( + cost_data + and "usage" in data_json + and data_json["usage"] + ): + data_json["usage"]["cost_sats"] = ( + cost_data.total_msats // 1000 + ) + changed = True + if changed: + lines[i] = "data: " + json.dumps(data_json) + except json.JSONDecodeError: + pass async def generate() -> AsyncGenerator[bytes, None]: for line in lines: @@ -4164,6 +4217,7 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) + self._apply_provider_field(response_json) cost_data = await self.get_x_cashu_cost(response_json, max_cost_for_model) if cost_data and "usage" in response_json: diff --git a/tests/unit/test_provider_field_injection.py b/tests/unit/test_provider_field_injection.py new file mode 100644 index 00000000..93658a94 --- /dev/null +++ b/tests/unit/test_provider_field_injection.py @@ -0,0 +1,105 @@ +from routstr.upstream.anthropic import AnthropicUpstreamProvider +from routstr.upstream.base import BaseUpstreamProvider +from routstr.upstream.openrouter import OpenRouterUpstreamProvider + + +def _make_provider(cls: type, provider_type: str) -> BaseUpstreamProvider: + p = cls(api_key="test_key") + assert p.provider_type == provider_type + return p + + +def test_apply_provider_field_direct_upstream() -> None: + """For a direct upstream (no upstream-reported provider), the field + is just the provider_type string.""" + p = _make_provider(AnthropicUpstreamProvider, "anthropic") + data: dict = {"id": "msg_1", "model": "claude-3-5-sonnet"} + p._apply_provider_field(data) + assert data["provider"] == "anthropic" + + +def test_apply_provider_field_openrouter_passthrough() -> None: + """OpenRouter responses include an upstream ``provider`` string — + routstr should prefix with its own provider_type.""" + p = _make_provider(OpenRouterUpstreamProvider, "openrouter") + data: dict = { + "id": "gen-abc", + "model": "anthropic/claude-3.5-sonnet", + "provider": "Anthropic", + } + p._apply_provider_field(data) + assert data["provider"] == "openrouter:Anthropic" + + +def test_apply_provider_field_openrouter_no_upstream_provider() -> None: + """If OpenRouter omits the provider field, fall back to provider_type.""" + p = _make_provider(OpenRouterUpstreamProvider, "openrouter") + data: dict = {"id": "gen-abc"} + p._apply_provider_field(data) + assert data["provider"] == "openrouter" + + +def test_apply_provider_field_strips_whitespace() -> None: + p = _make_provider(OpenRouterUpstreamProvider, "openrouter") + data: dict = {"provider": " Fireworks "} + p._apply_provider_field(data) + assert data["provider"] == "openrouter:Fireworks" + + +def test_apply_provider_field_blank_upstream_treated_as_missing() -> None: + p = _make_provider(OpenRouterUpstreamProvider, "openrouter") + data: dict = {"provider": " "} + p._apply_provider_field(data) + assert data["provider"] == "openrouter" + + +def test_apply_provider_field_non_string_upstream_treated_as_missing() -> None: + p = _make_provider(OpenRouterUpstreamProvider, "openrouter") + data: dict = {"provider": 42} + p._apply_provider_field(data) + assert data["provider"] == "openrouter" + + +def test_apply_provider_field_idempotent_for_direct_upstream() -> None: + """Calling twice on a direct upstream payload should keep the same + value, not nest the prefix repeatedly.""" + p = _make_provider(AnthropicUpstreamProvider, "anthropic") + data: dict = {} + p._apply_provider_field(data) + p._apply_provider_field(data) + assert data["provider"] == "anthropic:anthropic" + # Document current (deliberate) behavior: second pass treats the + # first-pass value as an upstream-reported provider. Callers should + # only invoke this once per chunk — guarded via the + # ``"provider" not in data`` checks in streaming paths. + + +def test_apply_provider_field_ignores_non_dict() -> None: + """Lists / primitives must be skipped silently.""" + p = _make_provider(AnthropicUpstreamProvider, "anthropic") + # Should not raise. + p._apply_provider_field([1, 2, 3]) # type: ignore[arg-type] + p._apply_provider_field("hello") # type: ignore[arg-type] + p._apply_provider_field(None) # type: ignore[arg-type] + + +def test_inject_cost_metadata_sets_provider() -> None: + """``inject_cost_metadata`` is the unified injection point and must + also stamp the provider field.""" + from unittest.mock import MagicMock + + from routstr.core.db import ApiKey + + p = _make_provider(OpenRouterUpstreamProvider, "openrouter") + key = MagicMock(spec=ApiKey) + key.balance = 1000 + + response_json: dict = { + "model": "anthropic/claude-3.5-sonnet", + "provider": "Anthropic", + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + } + cost_data = {"total_msats": 2500, "total_usd": 0.0025} + p.inject_cost_metadata(response_json, cost_data, key) + + assert response_json["provider"] == "openrouter:Anthropic" From d52b727bce12755311d9fc5871bfe2a5e0fe5edc Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 18 May 2026 20:26:38 +0800 Subject: [PATCH 02/12] fix: add SELinux :z labels and user root to podman-compose volumes - Added :z (shared SELinux label) to all host bind-mount volumes so containers can write when SELinux is enforcing - Set user: root on the ui service for compatibility with rootless podman's UID mapping --- compose.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/compose.yml b/compose.yml index 28f9c5d8..e24e4a8f 100644 --- a/compose.yml +++ b/compose.yml @@ -8,8 +8,9 @@ services: args: # NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8000} NEXT_PUBLIC_ADMIN_API_KEY: ${NEXT_PUBLIC_ADMIN_API_KEY:-} + user: root volumes: - - ./ui_out:/output + - ./ui_out:/output:z command: ["sh", "-c", "mkdir -p /output && cp -r /app/built/. /output/ && echo 'UI build copied to mounted volume' && ls -la /output/ && echo 'UI built and ready' && tail -f /dev/null"] @@ -18,10 +19,10 @@ services: depends_on: - ui volumes: - - .:/app - - ./logs:/app/logs + - .:/app:z + - ./logs:/app/logs:z - tor-data:/var/lib/tor:ro - - ./ui_out:/app/ui_out:ro + - ./ui_out:/app/ui_out:ro,z env_file: - .env environment: From e4165f1dabbda1878af8ef10b0bef61c843b661e Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 18 May 2026 21:50:19 +0800 Subject: [PATCH 03/12] passing through tee get requests --- routstr/proxy.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/routstr/proxy.py b/routstr/proxy.py index a6f75474..dcb32409 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -162,6 +162,7 @@ _API_PATH_PREFIXES = ( "images/", "moderations", "providers", + "tee/", ) @@ -182,6 +183,40 @@ async def proxy( request_body = await request.body() request_body_dict = parse_request_body_json(request_body, path) + # /tee/* GET requests (e.g. attestation) don't map to models — just + # forward to all enabled upstreams without model/cost/auth lookups. + if request.method == "GET" and path.startswith("tee/"): + all_upstreams = _upstreams + last_error_response = None + for i, upstream in enumerate(all_upstreams): + try: + headers = upstream.prepare_headers(dict(request.headers)) + response = await upstream.forward_get_request(request, path, headers) + if response.status_code in [502, 429] and i < len(all_upstreams) - 1: + logger.warning( + "Upstream %s returned %s for tee GET %s, trying next", + upstream.provider_type, + response.status_code, + path, + ) + continue + return response + except UpstreamError as e: + logger.warning( + "Upstream %s failed for tee GET %s: %s", + upstream.provider_type, + path, + e, + ) + if i == len(all_upstreams) - 1: + last_error_response = create_error_response( + "upstream_error", str(e), 502, request=request + ) + continue + return last_error_response or create_error_response( + "upstream_error", "All upstreams failed", 502, request=request + ) + if is_responses_api: model_id = extract_model_from_responses_request(request_body_dict) else: From 70ef3c357cbd2b37113cdbc0340971a8007ea2d0 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 18 May 2026 22:08:29 +0800 Subject: [PATCH 04/12] fixed the encoding bug. --- routstr/upstream/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 11a8a902..02f107e2 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -2818,10 +2818,13 @@ class BaseUpstreamProvider: await response.aclose() return mapped + response_headers = dict(response.headers) + response_headers.pop("content-encoding", None) + response_headers.pop("content-length", None) return StreamingResponse( response.aiter_bytes(), status_code=response.status_code, - headers=dict(response.headers), + headers=response_headers, ) except Exception as exc: tb = traceback.format_exc() From 32516645133f0c668588fb4e6a1093a30c604ac3 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 20 May 2026 22:55:35 +0200 Subject: [PATCH 05/12] use correct var to report balance info --- routstr/balance.py | 2 +- .../integration/test_insufficient_balance.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/routstr/balance.py b/routstr/balance.py index 05e91609..b3487826 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -45,7 +45,7 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: billing_key = await get_billing_key(key, session) info = { "api_key": "sk-" + key.hashed_key, - "balance": billing_key.balance, + "balance": billing_key.total_balance, "reserved": billing_key.reserved_balance, "is_child": key.parent_key_hash is not None, "parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None, diff --git a/tests/integration/test_insufficient_balance.py b/tests/integration/test_insufficient_balance.py index 489b1dbf..11860327 100644 --- a/tests/integration/test_insufficient_balance.py +++ b/tests/integration/test_insufficient_balance.py @@ -124,6 +124,40 @@ async def test_pay_for_request_raises_402_when_all_balance_reserved( assert key.reserved_balance == 50_000 +@pytest.mark.asyncio +async def test_balance_info_matches_chat_available_balance( + integration_client: AsyncClient, + integration_session: AsyncSession, +) -> None: + """ + Regression for /v1/balance/info showing gross funds while chat admission + rejects with a negative available balance. + """ + from routstr.auth import pay_for_request + + key = _key(balance=4_404_339, reserved=4_410_636) + integration_session.add(key) + await integration_session.commit() + + response = await integration_client.get( + "/v1/balance/info", + headers={"Authorization": f"Bearer sk-{key.hashed_key}"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["balance"] == -6_297 + assert body["reserved"] == 4_410_636 + + with pytest.raises(HTTPException) as exc_info: + await pay_for_request(key, 1, integration_session) + + assert exc_info.value.status_code == 402 + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert "-6297 available" in detail["error"]["message"] + + # --------------------------------------------------------------------------- # Test 4 — balance just one msat below model cost # --------------------------------------------------------------------------- From 632244e54f45b3fb68cb868d7d9905a9fab10d7b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 20 May 2026 23:24:24 +0200 Subject: [PATCH 06/12] add rop-08 lightning invoice support --- docs/api/endpoints.md | 2 + docs/client/payments.md | 4 +- routstr/balance.py | 2 +- routstr/core/main.py | 2 + routstr/lightning.py | 39 +++- .../test_lightning_invoice_rip08.py | 198 ++++++++++++++++++ 6 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 tests/integration/test_lightning_invoice_rip08.py diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index f6b0c0f0..5a9ea68d 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -396,6 +396,8 @@ Authorization: Bearer sk-... } ``` +`balance` is the spendable balance used by request admission. + ### Check Balance Get current wallet balance. diff --git a/docs/client/payments.md b/docs/client/payments.md index f8d3ffd2..93ac35a7 100644 --- a/docs/client/payments.md +++ b/docs/client/payments.md @@ -53,9 +53,11 @@ If your balance runs low, you don't need a new key. You can top up the existing ### Via Lightning -`POST /lightning/invoice` with `{"amount_sats": 1000, "purpose": "topup", "api_key": "sk-..."}`. +`POST /lightning/invoice` with `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`. *Once paid, the funds are added to your existing key.* +> Legacy: the endpoint is also exposed at `/v1/balance/lightning/invoice`, and accepts an `api_key` field in the body as a fallback for older clients. New integrations should use the RIP-08 path with the `Authorization` header. + ### Via Cashu `POST /v1/balance/topup` with `{"cashu_token": "..."}` and `Authorization: Bearer sk-...`. diff --git a/routstr/balance.py b/routstr/balance.py index 05e91609..a1c53552 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -642,7 +642,7 @@ async def wallet_catch_all(path: str) -> NoReturn: ) -balance_router.include_router(lightning_router) +balance_router.include_router(lightning_router, include_in_schema=False) balance_router.include_router(router) deprecated_wallet_router = APIRouter(prefix="/v1/wallet", include_in_schema=False) diff --git a/routstr/core/main.py b/routstr/core/main.py index 535480a2..de21bc68 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -13,6 +13,7 @@ from starlette.types import Scope from ..auth import periodic_key_reset from ..balance import balance_router, deprecated_wallet_router +from ..lightning import lightning_router from ..nostr import ( announce_provider, providers_cache_refresher, @@ -365,6 +366,7 @@ else: app.include_router(models_router) app.include_router(admin_router) app.include_router(balance_router) +app.include_router(lightning_router) app.include_router(deprecated_wallet_router) app.include_router(providers_router) app.include_router(proxy_router) diff --git a/routstr/lightning.py b/routstr/lightning.py index aecbb48f..870f339c 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -2,7 +2,7 @@ import hashlib import secrets import time -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -19,15 +19,29 @@ lightning_router = APIRouter(prefix="/lightning") class InvoiceCreateRequest(BaseModel): amount_sats: int = Field(gt=0, le=1_000_000, description="Amount in satoshis") - purpose: str = Field(description="create or topup", pattern="^(create|topup)$") + purpose: str = Field( + default="create", + description="create or topup", + pattern="^(create|topup)$", + ) api_key: str | None = Field( - default=None, description="Required for topup operations" + default=None, + description="Deprecated: legacy field for topup. Prefer Authorization header.", ) balance_limit: int | None = Field(default=None) balance_limit_reset: str | None = Field(default=None) validity_date: int | None = Field(default=None) +def _extract_bearer_api_key(authorization: str | None) -> str | None: + if not authorization: + return None + token = authorization.strip() + if token.lower().startswith("bearer "): + token = token[7:].strip() + return token or None + + class InvoiceCreateResponse(BaseModel): invoice_id: str bolt11: str @@ -64,18 +78,21 @@ def generate_invoice_id() -> str: @lightning_router.post("/invoice", response_model=InvoiceCreateResponse) async def create_invoice( request: InvoiceCreateRequest, + authorization: str | None = Header(default=None), session: AsyncSession = Depends(get_session), ) -> InvoiceCreateResponse: - if request.purpose == "topup" and not request.api_key: - raise HTTPException( - status_code=400, detail="api_key is required for topup operations" - ) + api_key_token = _extract_bearer_api_key(authorization) or request.api_key - if request.purpose == "topup" and request.api_key: - if not request.api_key.startswith("sk-"): + if request.purpose == "topup": + if not api_key_token: + raise HTTPException( + status_code=401, + detail="Authorization bearer api key is required for topup", + ) + if not api_key_token.startswith("sk-"): raise HTTPException(status_code=400, detail="Invalid API key format") - api_key = await session.get(ApiKey, request.api_key[3:]) + api_key = await session.get(ApiKey, api_key_token[3:]) if not api_key: raise HTTPException(status_code=404, detail="API key not found") @@ -95,7 +112,7 @@ async def create_invoice( description=description, payment_hash=payment_hash, status="pending", - api_key_hash=request.api_key[3:] if request.api_key else None, + api_key_hash=api_key_token[3:] if api_key_token else None, purpose=request.purpose, balance_limit=request.balance_limit, balance_limit_reset=request.balance_limit_reset, diff --git a/tests/integration/test_lightning_invoice_rip08.py b/tests/integration/test_lightning_invoice_rip08.py new file mode 100644 index 00000000..29301a42 --- /dev/null +++ b/tests/integration/test_lightning_invoice_rip08.py @@ -0,0 +1,198 @@ +"""RIP-08 lightning invoice endpoint tests. + +Verifies both the spec-compliant path (`POST /lightning/invoice` with +`Authorization: Bearer sk-...`) and the legacy path +(`POST /v1/balance/lightning/invoice` with `api_key` in body). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.core.db import ApiKey + +RIP08_PATH = "/lightning/invoice" +LEGACY_PATH = "/v1/balance/lightning/invoice" + + +@pytest_asyncio.fixture +async def patch_invoice_generation() -> Any: + """Stub out `generate_lightning_invoice` so no mint round-trip is needed.""" + counter = {"n": 0} + + async def fake_generate(amount_sats: int, description: str) -> tuple[str, str]: + counter["n"] += 1 + return ( + f"lnbc{amount_sats}n1pfakeinvoice{counter['n']}", + f"payment_hash_{counter['n']}", + ) + + with patch( + "routstr.lightning.generate_lightning_invoice", + side_effect=fake_generate, + ) as m: + yield m + + +@pytest_asyncio.fixture +async def seeded_topup_key(integration_session: AsyncSession) -> str: + """Insert an ApiKey row and return the public `sk-...` form.""" + hashed = "0" * 64 + key = ApiKey( + hashed_key=hashed, + balance=0, + refund_currency="sat", + refund_mint_url="http://localhost:3338", + ) + integration_session.add(key) + await integration_session.commit() + return f"sk-{hashed}" + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +async def test_create_invoice_purpose_create( + integration_client: AsyncClient, + patch_invoice_generation: Any, + path: str, +) -> None: + """`purpose=create` works on both paths and requires no auth.""" + resp = await integration_client.post( + path, + json={"amount_sats": 1000, "purpose": "create"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["amount_sats"] == 1000 + assert body["bolt11"].startswith("lnbc") + assert body["invoice_id"] + assert body["payment_hash"] + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +async def test_topup_with_authorization_header( + integration_client: AsyncClient, + patch_invoice_generation: Any, + seeded_topup_key: str, + path: str, +) -> None: + """RIP-08: topup using `Authorization: Bearer sk-...` header (no api_key in body).""" + resp = await integration_client.post( + path, + json={"amount_sats": 500, "purpose": "topup"}, + headers={"Authorization": f"Bearer {seeded_topup_key}"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["amount_sats"] == 500 + assert body["bolt11"].startswith("lnbc") + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +async def test_topup_with_legacy_api_key_in_body( + integration_client: AsyncClient, + patch_invoice_generation: Any, + seeded_topup_key: str, + path: str, +) -> None: + """Legacy: topup with `api_key` in body still accepted on both paths.""" + resp = await integration_client.post( + path, + json={ + "amount_sats": 250, + "purpose": "topup", + "api_key": seeded_topup_key, + }, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["amount_sats"] == 250 + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +async def test_topup_missing_auth_returns_401( + integration_client: AsyncClient, + patch_invoice_generation: Any, + path: str, +) -> None: + """Topup without any credential is rejected on both paths.""" + resp = await integration_client.post( + path, + json={"amount_sats": 100, "purpose": "topup"}, + ) + assert resp.status_code == 401 + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +async def test_topup_unknown_api_key_returns_404( + integration_client: AsyncClient, + patch_invoice_generation: Any, + path: str, +) -> None: + resp = await integration_client.post( + path, + json={"amount_sats": 100, "purpose": "topup"}, + headers={"Authorization": "Bearer sk-deadbeef"}, + ) + assert resp.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +async def test_invoice_status_404_for_unknown_id( + integration_client: AsyncClient, + path: str, +) -> None: + base = path.rsplit("/invoice", 1)[0] + "/invoice" + resp = await integration_client.get(f"{base}/does-not-exist/status") + assert resp.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_purpose_defaults_to_create( + integration_client: AsyncClient, + patch_invoice_generation: Any, +) -> None: + """Per RIP-08, `purpose` may be omitted and defaults to `create`.""" + resp = await integration_client.post( + RIP08_PATH, + json={"amount_sats": 100}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["amount_sats"] == 100 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_authorization_header_overrides_body_api_key( + integration_client: AsyncClient, + patch_invoice_generation: Any, + seeded_topup_key: str, +) -> None: + """Header api_key wins over body api_key: bogus body must not cause 404.""" + resp = await integration_client.post( + RIP08_PATH, + json={ + "amount_sats": 100, + "purpose": "topup", + "api_key": "sk-" + "f" * 64, # bogus body key + }, + headers={"Authorization": f"Bearer {seeded_topup_key}"}, + ) + assert resp.status_code == 200, resp.text From f7bd250c97dfc72306984b7eda3d1b1ea38b63bd Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 28 May 2026 20:09:48 +0200 Subject: [PATCH 07/12] better handling invoice payment --- routstr/core/admin.py | 47 ++++++ routstr/core/main.py | 8 +- routstr/lightning.py | 49 +++++- ui/app/transactions/page.tsx | 294 ++++++++++++++++++++++++++++++++++- ui/lib/api/services/admin.ts | 38 +++++ 5 files changed, 428 insertions(+), 8 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 7db77a67..a00dfe88 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -22,6 +22,7 @@ from .db import ( ApiKey, CashuTransaction, CliToken, + LightningInvoice, ModelRow, UpstreamProviderRow, create_session, @@ -1477,6 +1478,52 @@ async def get_transactions_api( } +@admin_router.get( + "/api/lightning-invoices", dependencies=[Depends(require_admin_api)] +) +async def get_lightning_invoices_api( + status: str | None = None, + purpose: str | None = None, + search: str | None = None, + limit: int = 50, + offset: int = 0, +) -> dict: + async with create_session() as session: + from sqlmodel import col, func + + base = select(LightningInvoice) + if status: + base = base.where(LightningInvoice.status == status) + if purpose: + base = base.where(LightningInvoice.purpose == purpose) + if search: + pattern = f"%{search}%" + base = base.where( + (col(LightningInvoice.id).like(pattern)) + | (col(LightningInvoice.bolt11).like(pattern)) + | (col(LightningInvoice.payment_hash).like(pattern)) + | (col(LightningInvoice.api_key_hash).like(pattern)) + ) + + count_result = await session.exec( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.one() + + stmt = ( + base.order_by(col(LightningInvoice.created_at).desc()) + .offset(offset) + .limit(limit) + ) + results = await session.exec(stmt) + invoices = results.all() + + return { + "invoices": [inv.dict() for inv in invoices], + "total": total, + } + + @admin_router.post( "/api/upstream-providers/{provider_id}/routstr/refund", dependencies=[Depends(require_admin_api)], diff --git a/routstr/core/main.py b/routstr/core/main.py index de21bc68..d9fb00eb 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -13,7 +13,7 @@ from starlette.types import Scope from ..auth import periodic_key_reset from ..balance import balance_router, deprecated_wallet_router -from ..lightning import lightning_router +from ..lightning import lightning_router, periodic_invoice_watcher from ..nostr import ( announce_provider, providers_cache_refresher, @@ -57,6 +57,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: auto_topup_task = None refund_sweep_task = None routstr_fee_task = None + invoice_watcher_task = None try: # Apply litellm-wide settings (drop_params, chat-completions URL, @@ -124,6 +125,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: auto_topup_task = asyncio.create_task(periodic_auto_topup()) refund_sweep_task = asyncio.create_task(periodic_refund_sweep()) routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout()) + invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher()) yield @@ -163,6 +165,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: refund_sweep_task.cancel() if routstr_fee_task is not None: routstr_fee_task.cancel() + if invoice_watcher_task is not None: + invoice_watcher_task.cancel() try: tasks_to_wait = [] @@ -190,6 +194,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(refund_sweep_task) if routstr_fee_task is not None: tasks_to_wait.append(routstr_fee_task) + if invoice_watcher_task is not None: + tasks_to_wait.append(invoice_watcher_task) if tasks_to_wait: await asyncio.gather(*tasks_to_wait, return_exceptions=True) diff --git a/routstr/lightning.py b/routstr/lightning.py index 870f339c..81423198 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -1,13 +1,14 @@ +import asyncio import hashlib import secrets import time from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field -from sqlmodel import select +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession -from .core.db import ApiKey, LightningInvoice, get_session +from .core.db import ApiKey, LightningInvoice, create_session, get_session from .core.logging import get_logger from .core.settings import settings from .wallet import get_wallet @@ -159,13 +160,13 @@ async def get_invoice_status( if not invoice: raise HTTPException(status_code=404, detail="Invoice not found") + if invoice.status == "pending": + await check_invoice_payment(invoice, session) + if invoice.status == "pending" and int(time.time()) > invoice.expires_at: invoice.status = "expired" await session.commit() - if invoice.status == "pending": - await check_invoice_payment(invoice, session) - api_key = None if invoice.status == "paid" and invoice.purpose == "create": if invoice.api_key_hash: @@ -291,3 +292,41 @@ async def topup_api_key_from_invoice( api_key.balance += invoice.amount_sats * 1000 # Convert to msats await session.flush() + + +INVOICE_WATCH_INTERVAL_SECONDS = 5 +INVOICE_WATCH_BATCH_LIMIT = 100 + + +async def periodic_invoice_watcher() -> None: + """Background task: detect paid Lightning invoices and credit balances. + + Removes the need for clients to poll the status endpoint after paying. + """ + while True: + try: + async with create_session() as session: + now = int(time.time()) + result = await session.exec( + select(LightningInvoice) + .where( + LightningInvoice.status == "pending", + col(LightningInvoice.expires_at) > now, + ) + .limit(INVOICE_WATCH_BATCH_LIMIT) + ) + pending = result.all() + for invoice in pending: + try: + await check_invoice_payment(invoice, session) + except Exception as e: + logger.error( + "Invoice watcher failed for invoice", + extra={"invoice_id": invoice.id, "error": str(e)}, + ) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Invoice watcher loop error: {e}") + + await asyncio.sleep(INVOICE_WATCH_INTERVAL_SECONDS) diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx index 8704515f..e1812bfb 100644 --- a/ui/app/transactions/page.tsx +++ b/ui/app/transactions/page.tsx @@ -53,7 +53,11 @@ import { ChevronLeft, ChevronRight, } from 'lucide-react'; -import { AdminService, type Transaction } from '@/lib/api/services/admin'; +import { + AdminService, + type Transaction, + type LightningInvoice, +} from '@/lib/api/services/admin'; import { format } from 'date-fns'; import { toast } from 'sonner'; @@ -200,6 +204,172 @@ function TransactionTable({ ); } +function LightningInvoiceTable({ + invoices, + copiedId, + onCopy, +}: { + invoices: LightningInvoice[]; + copiedId: string | null; + onCopy: (text: string, id: string) => void; +}) { + if (invoices.length === 0) { + return ( + + + + + + No invoices found + + Lightning invoices created via /lightning/invoice will show here. + + + + ); + } + + const statusBadge = (status: LightningInvoice['status']) => { + if (status === 'paid') + return ( + + Paid + + ); + if (status === 'expired') + return ( + + Expired + + ); + if (status === 'cancelled') + return ( + + Cancelled + + ); + return ( + + Pending + + ); + }; + + return ( + +
+ + + + Purpose + Amount + Status + API Key + Payment Hash + Created + Paid + Actions + + + + {invoices.map((inv) => ( + + + {inv.purpose} + + + {inv.amount_sats} sat + + {statusBadge(inv.status)} + + {inv.api_key_hash ? ( +
+ + {inv.api_key_hash.slice(0, 12)}... + + +
+ ) : ( + + )} +
+ +
+ + {inv.payment_hash.slice(0, 14)}... + + +
+
+ + {format(inv.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')} + + + {inv.paid_at + ? format(inv.paid_at * 1000, 'yyyy-MM-dd HH:mm:ss') + : '—'} + + + + +
+ ))} +
+
+
+ +
+ ); +} + export default function TransactionsPage() { const [search, setSearch] = useState(''); const [type, setType] = useState('all'); @@ -231,6 +401,7 @@ export default function TransactionsPage() { const [activeTab, setActiveTab] = useState('x-cashu'); const [xcashuPage, setXcashuPage] = useState(0); const [apikeyPage, setApikeyPage] = useState(0); + const [lightningPage, setLightningPage] = useState(0); const typeParam = type === 'all' ? undefined : type; const statusParam = status === 'all' ? undefined : status; @@ -278,12 +449,37 @@ export default function TransactionsPage() { placeholderData: keepPreviousData, }); + const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled']; + const lightningStatusParam = LIGHTNING_STATUSES.includes(status) + ? status + : undefined; + + const lightningQuery = useQuery({ + queryKey: [ + 'lightning-invoices', + lightningStatusParam, + searchParam, + lightningPage, + ], + queryFn: () => + AdminService.getLightningInvoices( + lightningStatusParam, + undefined, + searchParam, + PAGE_SIZE, + lightningPage * PAGE_SIZE + ), + placeholderData: keepPreviousData, + refetchInterval: 10000, + }); + const handleClearFilters = () => { setSearch(''); setType('all'); setStatus('all'); setXcashuPage(0); setApikeyPage(0); + setLightningPage(0); }; const copyToClipboard = (text: string, id: string) => { @@ -337,9 +533,13 @@ export default function TransactionsPage() { useEffect(() => { setXcashuPage(0); setApikeyPage(0); + setLightningPage(0); }, [type, status, search]); - const isRefetching = xcashuQuery.isRefetching || apikeyQuery.isRefetching; + const isRefetching = + xcashuQuery.isRefetching || + apikeyQuery.isRefetching || + lightningQuery.isRefetching; const renderCardContent = ( query: typeof xcashuQuery, @@ -417,6 +617,7 @@ export default function TransactionsPage() { onClick={() => { xcashuQuery.refetch(); apikeyQuery.refetch(); + lightningQuery.refetch(); }} variant='outline' size='sm' @@ -476,6 +677,11 @@ export default function TransactionsPage() { Pending Collected Swept + Paid (Lightning) + Expired (Lightning) + + Cancelled (Lightning) + @@ -516,6 +722,15 @@ export default function TransactionsPage() { )} + + + Lightning + {lightningQuery.data && ( + + {lightningQuery.data.total} + + )} + @@ -553,6 +768,81 @@ export default function TransactionsPage() { + + + + +
+ Lightning Invoice History + + Auto-refreshing every 10s. Paid invoices credit balance + automatically. + +
+
+ + {lightningQuery.isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ) : ( + <> + {(() => { + const total = lightningQuery.data?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + if (totalPages <= 1) return null; + return ( +
+ + {lightningPage * PAGE_SIZE + 1}– + {Math.min((lightningPage + 1) * PAGE_SIZE, total)}{' '} + of {total} + +
+ + + {lightningPage + 1} / {totalPages} + + +
+
+ ); + })()} + + + )} +
+
+
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts index 0f7cc3f8..82b1c5e8 100644 --- a/ui/lib/api/services/admin.ts +++ b/ui/lib/api/services/admin.ts @@ -908,6 +908,25 @@ export class AdminService { ); } + static async getLightningInvoices( + status?: string, + purpose?: string, + search?: string, + limit: number = 50, + offset: number = 0 + ): Promise { + const params = new URLSearchParams(); + if (status) params.append('status', status); + if (purpose) params.append('purpose', purpose); + if (search) params.append('search', search); + params.append('limit', limit.toString()); + params.append('offset', offset.toString()); + + return await apiClient.get( + `/admin/api/lightning-invoices?${params.toString()}` + ); + } + static async createProviderAccountByType(providerType: string): Promise<{ ok: boolean; account_data: Record; @@ -1186,3 +1205,22 @@ export interface TransactionsResponse { transactions: Transaction[]; total: number; } + +export interface LightningInvoice { + id: string; + bolt11: string; + amount_sats: number; + description: string; + payment_hash: string; + status: 'pending' | 'paid' | 'expired' | 'cancelled'; + api_key_hash: string | null; + purpose: 'create' | 'topup'; + created_at: number; + expires_at: number; + paid_at: number | null; +} + +export interface LightningInvoicesResponse { + invoices: LightningInvoice[]; + total: number; +} From eddc0706286978ed4c6a904ba24dbc92d1a96355 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 28 May 2026 20:23:42 +0200 Subject: [PATCH 08/12] remove secp256k1 dependency --- Dockerfile | 22 +++++----------------- Dockerfile.full | 15 +-------------- pyproject.toml | 2 -- uv.lock | 16 +++++++++++++--- 4 files changed, 19 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index ffb405a1..36bebe43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,14 @@ -FROM ghcr.io/astral-sh/uv:python3.11-alpine +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim -# Install system dependencies required for secp256k1 -RUN apk add --no-cache \ - pkgconf \ - build-base \ - automake \ - autoconf \ - libtool \ - m4 \ - perl -RUN apk add git +WORKDIR /app COPY uv.lock pyproject.toml ./ RUN mkdir -p /routstr -RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060 -# RUN uv sync - -WORKDIR /app - COPY . . +RUN uv sync --frozen --no-dev + ARG GIT_COMMIT="" ARG GIT_TAG="" ENV GIT_COMMIT=${GIT_COMMIT} @@ -30,4 +18,4 @@ ENV PYTHONUNBUFFERED=1 EXPOSE 8000 -CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] +CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] diff --git a/Dockerfile.full b/Dockerfile.full index 23bdbff6..cc3e0e81 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -16,26 +16,13 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm run build # Stage 2: Build the Routstr Node -FROM ghcr.io/astral-sh/uv:python3.11-alpine AS runner - -# Install system dependencies -RUN apk add --no-cache \ - pkgconf \ - build-base \ - automake \ - autoconf \ - libtool \ - m4 \ - perl \ - git +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner WORKDIR /app # Copy the rest of the application (required for uv sync to find the package) COPY . . -# Install dependencies including the specific secp256k1 branch -RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060 RUN uv sync --no-dev # Copy the built UI from the ui-builder stage diff --git a/pyproject.toml b/pyproject.toml index dab818b7..694d3b79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ dependencies = [ "alembic>=1.13", "python-json-logger>=2.0.0", "cashu>=0.20", - "secp256k1", "marshmallow>=3.13,<4.0", "websockets>=12.0", "nostr>=0.0.2", @@ -87,4 +86,3 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } -secp256k1 = { git = "https://github.com/saschanaz/secp256k1-py", branch = "upgrade060" } diff --git a/uv.lock b/uv.lock index 900f9cfe..34df7542 100644 --- a/uv.lock +++ b/uv.lock @@ -2400,7 +2400,6 @@ dependencies = [ { name = "openai" }, { name = "pillow" }, { name = "python-json-logger" }, - { name = "secp256k1" }, { name = "sqlmodel" }, { name = "websockets" }, ] @@ -2435,7 +2434,6 @@ requires-dist = [ { name = "openai", specifier = ">=1.98.0" }, { name = "pillow", specifier = ">=10" }, { name = "python-json-logger", specifier = ">=2.0.0" }, - { name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" }, { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "websockets", specifier = ">=12.0" }, ] @@ -2591,10 +2589,22 @@ wheels = [ [[package]] name = "secp256k1" version = "0.14.0" -source = { git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060#7d70a8ec7ca2db050d292c3759e49e75e21ac533" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/9b/41/bb668a6e4192303542d2d90c3b38d564af3c17c61bd7d4039af4f29405fe/secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", size = 2420607, upload-time = "2021-11-06T01:36:10.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/12/4c9815a819816587df70aa38fe7d09b54724a0b1b9b8e8ea2af1c205f2a5/secp256k1-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539d1d9750299ec4e8df6211978ba78779f5095c7ef19985313f03d1d1b816bd", size = 1298105, upload-time = "2026-01-29T16:26:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/b1/86/f01ee0f4c44e12933c460f2b868a3888b93a7c7f4e9fc9be173401b55e8d/secp256k1-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d597a59e3918b0e41181a1c872851ac2e6137882de7f0487b8c42b25333ada", size = 1498906, upload-time = "2026-01-29T16:26:30.138Z" }, + { url = "https://files.pythonhosted.org/packages/05/c8/79f2990b72556c3f416ecfde2116a08afb41e324f51b8bf61268d7b72715/secp256k1-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:393d189b4ada9ab3de0b053f484a3b7e86024f4b8cd36616c05f07dbae3ca180", size = 1494612, upload-time = "2026-01-29T16:26:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e8/8dd140270b4e12a7f5876f1641f996854d700866352875f161f770b69ebb/secp256k1-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4ec14534c1e8b8991376915ef059b7a3e62366aeda60df50b3932ad6529d26a", size = 1298100, upload-time = "2026-01-29T16:26:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6c/e63892de8d7582ab30602ccc1cf0ecd88a30b1a09424eb847c863fd46d9f/secp256k1-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1041694e429eb465123cb742911d2aad5cbd9e0cf2891aaaf794a887938647d1", size = 1499269, upload-time = "2026-01-29T16:26:35.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5c/2faa8c523c0204af249890eb51b697e9a19d59d101625149d7b4f482e894/secp256k1-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf03e6d45892172046d4e085d5cc91d13a73a465c0f4c8b5633d823b0ca667e2", size = 1494878, upload-time = "2026-01-29T16:26:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/702d5683d211644f4d286463d7b1c25aeed26275f7b0e2a5a8dc83e7a598/secp256k1-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d90725a63e8e1d6d1483a135649c30ba949185702d3e5acbc075cdab3a44a37f", size = 1298097, upload-time = "2026-01-29T16:26:39.653Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1e/928647ac138fddfb4c5ee8aa4140a5786e51c75e9062b7f8d1a0362565df/secp256k1-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cd60d76d95e2eb977edc6523d1178a496fa1634517b497d4cdc7c9aa5e93aa3", size = 1499198, upload-time = "2026-01-29T16:26:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/30/c4168076a3cd66ce8ddb28ea127a5f97b088452f1ccb2a3208219fc4f77b/secp256k1-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:245b91f4bfe3a151e3e361f7e7ed634744d35e87c9ac6cf3eb0e4269801d9f7e", size = 1494778, upload-time = "2026-01-29T16:26:43.167Z" }, +] [[package]] name = "setuptools" From ed2c8c9fe296a77c474288bc535197eeb2503816 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 30 May 2026 17:00:57 +0200 Subject: [PATCH 09/12] fix compose file --- Dockerfile | 10 ++++++++++ Dockerfile.full | 10 ++++++++++ compose.yml | 1 + 3 files changed, 21 insertions(+) diff --git a/Dockerfile b/Dockerfile index 36bebe43..42499d50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,16 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libsecp256k1-dev \ + autoconf \ + automake \ + libtool \ + && rm -rf /var/lib/apt/lists/* + COPY uv.lock pyproject.toml ./ RUN mkdir -p /routstr diff --git a/Dockerfile.full b/Dockerfile.full index cc3e0e81..a625f4ce 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -20,6 +20,16 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libsecp256k1-dev \ + autoconf \ + automake \ + libtool \ + && rm -rf /var/lib/apt/lists/* + # Copy the rest of the application (required for uv sync to find the package) COPY . . diff --git a/compose.yml b/compose.yml index e24e4a8f..0df234e2 100644 --- a/compose.yml +++ b/compose.yml @@ -20,6 +20,7 @@ services: - ui volumes: - .:/app:z + - /app/.venv - ./logs:/app/logs:z - tor-data:/var/lib/tor:ro - ./ui_out:/app/ui_out:ro,z From ee79a305bad678d5cbf89a371f24488497d59b36 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 30 May 2026 17:20:16 +0200 Subject: [PATCH 10/12] revert --- Dockerfile | 10 +++++----- Dockerfile.full | 13 +++++++------ compose.yml | 1 - 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 42499d50..0494517f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim -WORKDIR /app - RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ @@ -15,9 +13,11 @@ RUN apt-get update \ COPY uv.lock pyproject.toml ./ RUN mkdir -p /routstr -COPY . . +RUN uv sync --frozen --no-dev --no-install-project -RUN uv sync --frozen --no-dev +WORKDIR /app + +COPY . . ARG GIT_COMMIT="" ARG GIT_TAG="" @@ -28,4 +28,4 @@ ENV PYTHONUNBUFFERED=1 EXPOSE 8000 -CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] +CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] diff --git a/Dockerfile.full b/Dockerfile.full index a625f4ce..31e68d0a 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -18,8 +18,6 @@ RUN pnpm run build # Stage 2: Build the Routstr Node FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner -WORKDIR /app - RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ @@ -30,10 +28,13 @@ RUN apt-get update \ libtool \ && rm -rf /var/lib/apt/lists/* -# Copy the rest of the application (required for uv sync to find the package) -COPY . . +COPY uv.lock pyproject.toml ./ -RUN uv sync --no-dev +RUN uv sync --no-dev --no-install-project + +WORKDIR /app + +COPY . . # Copy the built UI from the ui-builder stage COPY --from=ui-builder /app/ui/out ./ui_out @@ -48,4 +49,4 @@ ENV PYTHONUNBUFFERED=1 EXPOSE 8000 # Run the application -CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] +CMD ["/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] diff --git a/compose.yml b/compose.yml index 0df234e2..e24e4a8f 100644 --- a/compose.yml +++ b/compose.yml @@ -20,7 +20,6 @@ services: - ui volumes: - .:/app:z - - /app/.venv - ./logs:/app/logs:z - tor-data:/var/lib/tor:ro - ./ui_out:/app/ui_out:ro,z From d345d3b53fc2db23ebd2b4de8126e06af35f1f37 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 30 May 2026 17:56:03 +0200 Subject: [PATCH 11/12] add missing git dep. to display correct commit --- Dockerfile | 1 + Dockerfile.full | 1 + 2 files changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0494517f..23140ccf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ + git \ build-essential \ pkg-config \ libsecp256k1-dev \ diff --git a/Dockerfile.full b/Dockerfile.full index 31e68d0a..4e80dd03 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -20,6 +20,7 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner RUN apt-get update \ && apt-get install -y --no-install-recommends \ + git \ build-essential \ pkg-config \ libsecp256k1-dev \ From c4d0a1afba2a7579688f8aed126837d696f85cb6 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 30 May 2026 20:02:44 +0200 Subject: [PATCH 12/12] wrap long log to not overflow --- ui/app/logs/log-details-dialog.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx index 627d8c04..4192c5b1 100644 --- a/ui/app/logs/log-details-dialog.tsx +++ b/ui/app/logs/log-details-dialog.tsx @@ -71,8 +71,8 @@ export function LogDetailsDialog({

Message

-
-
+              
+
                   {log.message}
                 
@@ -113,8 +113,8 @@ export function LogDetailsDialog({ )}
-
-
+                    
+
                         {String(log[field as keyof LogEntry] || 'N/A')}
                       
@@ -132,13 +132,13 @@ export function LogDetailsDialog({ {field} -
+
{typeof log[field] === 'object' ? ( -
+                          
                             {JSON.stringify(log[field], null, 2)}
                           
) : ( -
+                          
                             {String(log[field] || 'N/A')}
                           
)} @@ -173,8 +173,8 @@ export function LogDetailsDialog({ )}
-
-
+              
+
                   {JSON.stringify(log, null, 2)}