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 b3487826..e50bafc2 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