diff --git a/Dockerfile.full b/Dockerfile.full new file mode 100644 index 00000000..923b6e8f --- /dev/null +++ b/Dockerfile.full @@ -0,0 +1,50 @@ +# Multi-stage Dockerfile for Routstr (includes UI build) +# Stage 1: Build the UI +FROM node:23-alpine AS ui-builder +WORKDIR /app/ui + +# Install pnpm +RUN corepack enable pnpm && corepack prepare pnpm@latest --activate + +# Copy UI source +COPY ui/package.json ui/pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile + +COPY ui/ ./ +ENV NEXT_TELEMETRY_DISABLED=1 +# Next.js build produces a static export in 'out' directory +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 + +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 +COPY --from=ui-builder /app/ui/out ./ui_out + +ENV PORT=8000 +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +# Run the application +CMD ["/app/.venv/bin/fastapi", "run", "routstr", "--host", "0.0.0.0"] diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 952c9373..f6b0c0f0 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -360,6 +360,42 @@ POST /v1/wallet/create } ``` +### Get Key Information + +Get current balance, consumption data, and child keys for an API key. + +```http +GET /v1/balance/info +Authorization: Bearer sk-... +``` + +**Response:** + +```json +{ + "api_key": "sk-abc...", + "balance": 8500000, + "reserved": 0, + "is_child": false, + "parent_key": null, + "total_requests": 42, + "total_spent": 1500000, + "balance_limit": null, + "balance_limit_reset": null, + "validity_date": null, + "child_keys": [ + { + "api_key": "sk-child1...", + "total_requests": 10, + "total_spent": 500000, + "balance_limit": 1000000, + "balance_limit_reset": "daily", + "validity_date": 1738000000 + } + ] +} +``` + ### Check Balance Get current wallet balance. diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index aaa00e86..633ccd10 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -6,6 +6,32 @@ For automated deployments, you can optionally pre-configure settings via environ --- +## Initial Setup (.env file) + +Before running your node, you should create a `.env` file in the project root. This file is used to bootstrap the initial configuration and store sensitive secrets. + +### Example .env + +```bash +ADMIN_PASSWORD=your-secure-password + +# Node Identity +NAME="My AI Node" +DESCRIPTION="Fast access to models" + +# Lightning Payouts +RECEIVE_LN_ADDRESS=yourname@wallet.com +``` + +### Setting the UI Password + +There are two ways to set or change your Admin Dashboard password: + +1. **Via Environment Variable**: Set `ADMIN_PASSWORD` in your `.env` file before starting the container. This will be the password used for the first login. +2. **Via Dashboard**: Once logged in, go to **Settings** → **Security** to update your password. Dashboard settings override the `.env` file once saved. + +--- + ## Admin Dashboard (Primary) Access the dashboard at `/admin/` on your node. @@ -14,29 +40,29 @@ Access the dashboard at `/admin/` on your node. Connect to your AI provider(s): -| Setting | Description | -|---------|-------------| +| Setting | Description | +| ---------------- | ------------------------------------------------ | | **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) | -| **API Key** | Your provider's API key | +| **API Key** | Your provider's API key | ### Node Identity How your node appears to clients: -| Setting | Description | -|---------|-------------| -| **Name** | Display name (e.g., "Fast GPT-4 Node") | -| **Description** | Brief description of your service | +| Setting | Description | +| --------------- | -------------------------------------- | +| **Name** | Display name (e.g., "Fast GPT-4 Node") | +| **Description** | Brief description of your service | ### Pricing Control your profit margins: -| Setting | Description | Default | -|---------|-------------|---------| -| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off | -| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) | -| **Upstream Fee** | Your profit markup | 1.10 (10%) | +| Setting | Description | Default | +| ----------------- | ------------------------------------------ | ------------ | +| **Fixed Pricing** | Charge flat rate per request vs. per-token | Off | +| **Exchange Fee** | Buffer for BTC volatility | 1.005 (0.5%) | +| **Upstream Fee** | Your profit markup | 1.10 (10%) | See [Pricing](pricing.md) for detailed strategies. @@ -44,33 +70,33 @@ See [Pricing](pricing.md) for detailed strategies. Which mints to accept payments from: -| Setting | Description | -|---------|-------------| +| Setting | Description | +| --------- | ------------------------------- | | **Mints** | List of trusted Cashu mint URLs | ### Lightning Withdrawals Automatic profit withdrawal: -| Setting | Description | -|---------|-------------| +| Setting | Description | +| --------------------- | ------------------------------- | | **Lightning Address** | Your LN address for withdrawals | ### Security -| Setting | Description | -|---------|-------------| +| Setting | Description | +| ------------------ | ----------------------------- | | **Admin Password** | Password for dashboard access | ### Nostr Discovery Announce your node on the network: -| Setting | Description | -|---------|-------------| -| **Npub** | Your Nostr public key | -| **Nsec** | Your Nostr private key (for signing) | -| **Relays** | Relays to publish announcements | +| Setting | Description | +| ---------- | ------------------------------------ | +| **Npub** | Your Nostr public key | +| **Nsec** | Your Nostr private key (for signing) | +| **Relays** | Relays to publish announcements | See [Discovery](discovery.md) for details. @@ -86,21 +112,21 @@ Use environment variables for: ### All Variables -| Variable | Description | Default | -|----------|-------------|---------| -| `UPSTREAM_BASE_URL` | Upstream API endpoint | — | -| `UPSTREAM_API_KEY` | Upstream API key | — | -| `ADMIN_PASSWORD` | Dashboard password | (none) | -| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` | -| `NAME` | Node display name | `ARoutstrNode` | -| `DESCRIPTION` | Node description | `A Routstr Node` | -| `NPUB` | Nostr public key (bech32) | — | -| `NSEC` | Nostr private key | — | -| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` | -| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — | -| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` | -| `CORS_ORIGINS` | Allowed CORS origins | `*` | -| `RELAYS` | Nostr relays (comma-separated) | (default set) | +| Variable | Description | Default | +| -------------------- | --------------------------------- | ------------------------------------ | +| `UPSTREAM_BASE_URL` | Upstream API endpoint | — | +| `UPSTREAM_API_KEY` | Upstream API key | — | +| `ADMIN_PASSWORD` | Dashboard password | (none) | +| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///keys.db` | +| `NAME` | Node display name | `ARoutstrNode` | +| `DESCRIPTION` | Node description | `A Routstr Node` | +| `NPUB` | Nostr public key (bech32) | — | +| `NSEC` | Nostr private key | — | +| `CASHU_MINTS` | Comma-separated mint URLs | `https://mint.minibits.cash/Bitcoin` | +| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — | +| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` | +| `CORS_ORIGINS` | Allowed CORS origins | `*` | +| `RELAYS` | Nostr relays (comma-separated) | (default set) | ### Priority diff --git a/docs/provider/deployment.md b/docs/provider/deployment.md index 4ad21e78..99f0166a 100644 --- a/docs/provider/deployment.md +++ b/docs/provider/deployment.md @@ -6,30 +6,25 @@ Production deployment guide for Routstr Provider nodes. For production, use Docker Compose with persistent storage and optional Tor support. -### Basic Setup +### Unified Setup (All-in-one) +To build and run the node with the UI integrated in a single container using the multi-stage build: -Create a `compose.yml`: - -```yaml -services: - routstr: - image: ghcr.io/routstr/proxy:latest - container_name: routstr - restart: unless-stopped - ports: - - "8000:8000" - volumes: - - ./data:/app/data - - ./logs:/app/logs +```bash +docker build -f Dockerfile.full -t routstr-full . +docker run -d -p 8000:8000 --env-file .env routstr-full ``` -Start the node: +### Advanced Setup (Separated UI & Node) +Use the included `compose.yml` for a more flexible setup that separates the UI build process from the node execution. This is useful for development or when you want to manage Tor as a separate service. ```bash docker compose up -d ``` -Then configure everything via the [Admin Dashboard](http://localhost:8000/admin/). +This will: +1. **Build the UI**: Compiles the frontend and copies it to a shared volume. +2. **Start Routstr**: Runs the Python node, mounting the built UI. +3. **Start Tor**: Provides anonymous access via a `.onion` address. --- @@ -189,8 +184,20 @@ docker compose up -d ## Building from Source +### Unified Image (UI + Node) +The easiest way to build everything from source into a single production-ready image: + ```bash -git clone https://github.com/routstr/routstr-core.git -cd routstr-core -docker build -t routstr-local . +docker build -f Dockerfile.full -t routstr-full . +``` + +### Individual Components +If you prefer building them separately or using Docker Compose: + +```bash +# Build using compose +docker compose build + +# Or build the node only (requires manual UI build first) +docker build -t routstr-node . ``` diff --git a/docs/provider/quickstart.md b/docs/provider/quickstart.md index 2771c1f3..28e6c593 100644 --- a/docs/provider/quickstart.md +++ b/docs/provider/quickstart.md @@ -13,7 +13,7 @@ A **Routstr Provider Node** acts as a gateway that: You bring the API keys, Routstr handles the billing, payments, and client management. !!! tip "Future: Node-to-Node Routing" - In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials. +In future versions, you'll be able to run a node that connects to other Routstr nodes—eliminating the need to configure upstream providers yourself. For now, you'll need your own API credentials. --- @@ -24,16 +24,53 @@ You bring the API keys, Routstr handles the billing, payments, and client manage --- -## 1. Start the Node +## 1. Prepare Configuration + +Create a `.env` file in the root of the project to store your secrets: + +```bash +# Initial Admin Password +ADMIN_PASSWORD=mysecretpassword + +# Node Identity +NAME="My AI Node" +DESCRIPTION="Fast access to models" + +# Lightning Payouts +RECEIVE_LN_ADDRESS=yourname@wallet.com + +``` + +## 2. Start the Node + +You can run the pre-built image directly: ```bash docker run -d \ --name routstr \ -p 8000:8000 \ + --env-file .env \ -v routstr-data:/app/data \ ghcr.io/routstr/proxy:latest ``` +*Note: The pre-built image does not contain the UI. For the all-in-one experience with the Admin Dashboard, use the Build from Source instructions below.* + +### Build from Source (Recommended) + +If you want to build the node and UI yourself from source, use the unified Dockerfile: + +```bash +git clone https://github.com/routstr/routstr-core.git +cd routstr-core +# Edit your .env with ADMIN_PASSWORD and API keys +cp .env.example .env +nano .env + +docker build -f Dockerfile.full -t routstr-local . +docker run -d -p 8000:8000 --env-file .env --name routstr routstr-local +``` + Verify it's running: ```bash @@ -42,12 +79,12 @@ curl http://localhost:8000/v1/info --- -## 2. Configure via Dashboard +## 3. Configure via Dashboard Open the **Admin Dashboard** at [http://localhost:8000/admin/](http://localhost:8000/admin/). -!!! note "Default Access" - The dashboard has no password by default. Set one immediately in Settings for production use. +!!! note "Login" +Use the `ADMIN_PASSWORD` you defined in your `.env` file to log in. If you didn't set one, the dashboard will prompt you to set one on first visit. ### Connect Your AI Providers diff --git a/routstr/balance.py b/routstr/balance.py index 537a5324..84ea01d9 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -6,8 +6,9 @@ from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel +from sqlmodel import select -from .auth import validate_bearer_key +from .auth import get_billing_key, validate_bearer_key from .core.db import ApiKey, AsyncSession, get_session from .core.logging import get_logger from .core.settings import settings @@ -34,10 +35,8 @@ async def get_key_from_header( async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: - from .auth import get_billing_key - billing_key = await get_billing_key(key, session) - return { + info = { "api_key": "sk-" + key.hashed_key, "balance": billing_key.balance, "reserved": billing_key.reserved_balance, @@ -50,6 +49,26 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: "validity_date": key.validity_date, } + if not key.parent_key_hash: + # Fetch child keys if this is a parent key + statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key) + results = await session.exec(statement) + child_keys = results.all() + if child_keys: + info["child_keys"] = [ + { + "api_key": "sk-" + ck.hashed_key, + "total_requests": ck.total_requests, + "total_spent": ck.total_spent, + "balance_limit": ck.balance_limit, + "balance_limit_reset": ck.balance_limit_reset, + "validity_date": ck.validity_date, + } + for ck in child_keys + ] + + return info + # TODO: remove this endpoint when frontend is updated @router.get("/", include_in_schema=False) @@ -117,8 +136,6 @@ async def topup_wallet_endpoint( key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), ) -> dict[str, int]: - from .auth import get_billing_key - billing_key = await get_billing_key(key, session) if topup_request is not None: diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c0ccbe93..9aa8e81f 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -456,18 +456,18 @@ async def batch_override_provider_models( logger.info( f"BATCH_OVERRIDE called: provider_id={provider_id}, count={len(payload.models)}" ) - + async with create_session() as session: provider = await session.get(UpstreamProviderRow, provider_id) if not provider: raise HTTPException(status_code=404, detail="Provider not found") - + overridden_count = 0 - + for model_data in payload.models: # Try to get existing model regardless of whether it's enabled or not existing_row = await session.get(ModelRow, (model_data.id, provider_id)) - + if existing_row: # Update existing existing_row.name = model_data.name @@ -483,7 +483,9 @@ async def batch_override_provider_models( else None ) existing_row.top_provider = ( - json.dumps(model_data.top_provider) if model_data.top_provider else None + json.dumps(model_data.top_provider) + if model_data.top_provider + else None ) existing_row.canonical_slug = model_data.canonical_slug existing_row.alias_ids = ( @@ -508,23 +510,32 @@ async def batch_override_provider_models( else None ), top_provider=( - json.dumps(model_data.top_provider) if model_data.top_provider else None + json.dumps(model_data.top_provider) + if model_data.top_provider + else None ), canonical_slug=model_data.canonical_slug, alias_ids=( - json.dumps(model_data.alias_ids) if model_data.alias_ids else None + json.dumps(model_data.alias_ids) + if model_data.alias_ids + else None ), upstream_provider_id=provider_id, enabled=model_data.enabled, ) session.add(row) - + overridden_count += 1 - + await session.commit() await refresh_model_maps() - return {"ok": True, "count": overridden_count, "message": f"Successfully batch overridden {overridden_count} models"} + return { + "ok": True, + "count": overridden_count, + "message": f"Successfully batch overridden {overridden_count} models", + } + class UpstreamProviderCreate(BaseModel): provider_type: str @@ -570,12 +581,14 @@ async def create_upstream_provider( async with create_session() as session: result = await session.exec( select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == payload.base_url + UpstreamProviderRow.base_url == payload.base_url, + UpstreamProviderRow.api_key == payload.api_key, ) ) if result.first(): raise HTTPException( - status_code=409, detail="Provider with this base URL already exists" + status_code=409, + detail="Provider with this base URL and API key already exists", ) provider = UpstreamProviderRow( diff --git a/routstr/core/db.py b/routstr/core/db.py index 71834d5a..ba1d3d88 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -1,4 +1,6 @@ import os +import pathlib +import sqlite3 import time from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -181,11 +183,53 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]: yield session +def fix_cashu_migrations() -> None: + """ + Fixes Cashu wallet migrations that are not idempotent. + This specifically addresses the 'duplicate column name: public_keys' error + in the keysets table of Cashu's internal SQLite databases. + """ + project_root = pathlib.Path(__file__).resolve().parents[2] + wallet_dir = project_root / ".wallet" + + if not wallet_dir.exists() or not wallet_dir.is_dir(): + return + + logger.info("Checking Cashu wallet databases for migration idempotency") + + for db_file in wallet_dir.glob("*.sqlite3"): + try: + conn = sqlite3.connect(db_file) + cursor = conn.cursor() + + # Check if keysets table exists + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'" + ) + if not cursor.fetchone(): + conn.close() + continue + + # Check if public_keys column exists + cursor.execute("PRAGMA table_info(keysets)") + columns = [info[1] for info in cursor.fetchall()] + + if "public_keys" not in columns: + logger.info(f"Adding missing public_keys column to {db_file.name}") + cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT") + conn.commit() + + conn.close() + except Exception as e: + logger.warning(f"Could not check/fix Cashu database {db_file}: {e}") + + def run_migrations() -> None: """Run Alembic migrations programmatically.""" - import pathlib - try: + # Run Cashu migration fix first + fix_cashu_migrations() + # Get the path to the alembic.ini file project_root = pathlib.Path(__file__).resolve().parents[2] alembic_ini_path = project_root / "alembic.ini" diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index afb9475f..5efe5b0c 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -5,21 +5,18 @@ import json import re import traceback from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Mapping +from typing import Mapping import httpx from fastapi import BackgroundTasks, HTTPException, Request from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel +from sqlmodel import select from ..auth import adjust_payment_for_tokens, revert_pay_for_request from ..core import get_logger -from ..core.db import ApiKey, AsyncSession, create_session +from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow, create_session from ..core.exceptions import UpstreamError - -if TYPE_CHECKING: - from ..core.db import UpstreamProviderRow - from ..payment.cost_calculation import ( CostData, CostDataError, @@ -32,6 +29,7 @@ from ..payment.models import ( Pricing, _calculate_usd_max_costs, _update_model_sats_pricing, + list_models, ) from ..payment.price import sats_usd_price from ..wallet import recieve_token, send_token @@ -3089,18 +3087,45 @@ class BaseUpstreamProvider: async def refresh_models_cache(self) -> None: """Refresh the in-memory models cache from upstream API.""" try: - models = await self.fetch_models() - models_with_fees = [self._apply_provider_fee_to_model(m) for m in models] + async with create_session() as session: + stmt = select(UpstreamProviderRow).where( + UpstreamProviderRow.base_url == self.base_url, + UpstreamProviderRow.api_key == self.api_key + ) + result = await session.exec(stmt) + + # .first() returns the object or None if not found + provider = result.first() + if not provider or not provider.id: + raise HTTPException(status_code=404, detail="Provider not found") - try: - sats_to_usd = sats_usd_price() - self._models_cache = [ - _update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees - ] - except Exception: - self._models_cache = models_with_fees + db_models = await list_models( + session=session, + upstream_id=provider.id, + include_disabled=False, + apply_fees=False, + ) + db_model_ids: set[str] = {model.id for model in db_models} + models = await self.fetch_models() + model_ids = [model.id for model in models] + diff = set(db_model_ids) - set(model_ids) - self._models_by_id = {m.id: m for m in self._models_cache} + for db_model_id in diff: + found_db_model = next((model_obj for model_obj in db_models if model_obj.id == db_model_id)) + models.append(found_db_model) + + models_with_fees = [self._apply_provider_fee_to_model(m) for m in models] + print([mode.id for mode in models_with_fees]) + + try: + sats_to_usd = sats_usd_price() + self._models_cache = [ + _update_model_sats_pricing(m, sats_to_usd) for m in models_with_fees + ] + except Exception: + self._models_cache = models_with_fees + + self._models_by_id = {m.id: m for m in self._models_cache} except Exception as e: logger.error( diff --git a/tests/integration/test_child_keys_api.py b/tests/integration/test_child_keys_api.py new file mode 100644 index 00000000..7bfaf2a8 --- /dev/null +++ b/tests/integration/test_child_keys_api.py @@ -0,0 +1,97 @@ +from typing import Any + +import pytest +from httpx import AsyncClient + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_info_returns_child_keys( + integration_client: AsyncClient, + authenticated_client: AsyncClient, + integration_session: Any, +) -> None: + """Test that GET /v1/wallet/info returns child keys for a parent key""" + + # 1. Get parent info to find its hashed_key + response = await authenticated_client.get("/v1/wallet/info") + assert response.status_code == 200 + parent_data = response.json() + parent_data["api_key"] + + # 2. Create child keys for this parent + # We need to use the parent's authentication for this + child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"} + create_response = await authenticated_client.post( + "/v1/wallet/child-key", json=child_payload + ) + assert create_response.status_code == 200 + create_data = create_response.json() + child_keys = create_data["api_keys"] + assert len(child_keys) == 2 + + # 3. Call /info again and check for child_keys + info_response = await authenticated_client.get("/v1/wallet/info") + assert info_response.status_code == 200 + info_data = info_response.json() + + assert "child_keys" in info_data + assert len(info_data["child_keys"]) == 2 + + # Verify child key details + for ck in info_data["child_keys"]: + assert ck["api_key"] in child_keys + assert ck["balance_limit"] == 1000 + assert ck["balance_limit_reset"] == "daily" + assert "total_spent" in ck + assert "total_requests" in ck + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_wallet_info_child_key_no_child_keys( + integration_client: AsyncClient, + authenticated_client: AsyncClient, + integration_session: Any, +) -> None: + """Test that GET /v1/wallet/info for a child key does NOT return child_keys""" + + # 1. Create a child key + child_payload = {"count": 1} + create_response = await authenticated_client.post( + "/v1/wallet/child-key", json=child_payload + ) + assert create_response.status_code == 200 + child_key = create_response.json()["api_keys"][0] + + # 2. Use the child key to get its info + integration_client.headers["Authorization"] = f"Bearer {child_key}" + info_response = await integration_client.get("/v1/wallet/info") + assert info_response.status_code == 200 + info_data = info_response.json() + + assert info_data["is_child"] is True + assert "child_keys" not in info_data + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_account_info_root_returns_child_keys( + authenticated_client: AsyncClient, +) -> None: + """Test that GET / returns child keys for a parent key (root endpoint)""" + + # 1. Create a child key + child_payload = {"count": 1} + await authenticated_client.post("/v1/wallet/child-key", json=child_payload) + + # 2. Call root endpoint /v1/balance/ + # Note: routstr/balance.py defines router = APIRouter() + # and it is included in balance_router with prefix /v1/balance + # The endpoint is @router.get("/") + response = await authenticated_client.get("/v1/balance/") + assert response.status_code == 200 + data = response.json() + + assert "child_keys" in data + assert len(data["child_keys"]) >= 1 diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index e37791e2..97ddae80 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -251,7 +251,7 @@ export function ChildKeyCreator({ )}
+ {walletInfo.parentKey}
+
+
+ + {formatSats(walletInfo.totalSpent)} sats +
++ {formatMsats(walletInfo.totalSpent)} msats +
+
+ {ck.api_key}
+
+ + Requests +
++ {ck.total_requests} +
++ Spent +
++ {formatSats(ck.total_spent)} sats +
++ Limit +
++ {ck.balance_limit + ? `${formatSats(ck.balance_limit)} sats` + : 'None'} +
++ Policy +
++ {ck.balance_limit_reset || 'None'} +
++ Expires +
++ {formatDate(ck.validity_date)} +
+