mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
fix: version lightning error contract under v2
This commit is contained in:
+23
-1
@@ -4,7 +4,7 @@ This guide covers error responses, codes, and handling strategies for the Routst
|
|||||||
|
|
||||||
## Error Response Format
|
## Error Response Format
|
||||||
|
|
||||||
All errors follow a consistent JSON structure:
|
Versioned endpoints use a structured JSON error object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -19,6 +19,28 @@ All errors follow a consistent JSON structure:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Lightning invoice errors
|
||||||
|
|
||||||
|
The `/v2/lightning/*` endpoints use the structured error object above. In the
|
||||||
|
HTTP response, `error` is available at the top level and mirrored under
|
||||||
|
`detail.error`. Clients should branch on `error.code`.
|
||||||
|
|
||||||
|
| Endpoint case | Status | `type` | `code` |
|
||||||
|
|---------------|--------|--------|--------|
|
||||||
|
| Top-up without a credential | 401 | `invalid_request_error` | `topup_authorization_required` |
|
||||||
|
| Top-up with a non-`sk-` credential | 400 | `invalid_request_error` | `topup_invalid_api_key_format` |
|
||||||
|
| Top-up target key not found | 404 | `invalid_request_error` | `topup_api_key_not_found` |
|
||||||
|
| Invoice not found during status or recovery | 404 | `invalid_request_error` | `invoice_not_found` |
|
||||||
|
| Cashu mint rate-limited | 503 | `mint_rate_limited` | `lightning_mint_rate_limited` |
|
||||||
|
| Cashu mint unreachable | 503 | `mint_unreachable` | `lightning_mint_unreachable` |
|
||||||
|
| Unexpected invoice creation failure | 500 | `api_error` | `invoice_creation_failed` |
|
||||||
|
|
||||||
|
Request validation failures, including non-positive or excessive amounts, use
|
||||||
|
FastAPI's standard 422 validation response. Only the 503 mint failures are retryable. Use backoff and honor any mint
|
||||||
|
cooldown. The compatibility endpoints `/lightning/*` and
|
||||||
|
`/v1/balance/lightning/*` retain their original string `detail` errors and
|
||||||
|
legacy status behavior.
|
||||||
|
|
||||||
## HTTP Status Codes
|
## HTTP Status Codes
|
||||||
|
|
||||||
| Status | Meaning | Common Causes |
|
| Status | Meaning | Common Causes |
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ If your session runs out of funds, the API will return a `402` error.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
|
**Action**: Top up your key using the `/v2/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints.
|
||||||
|
|
||||||
### Rate Limiting
|
### Rate Limiting
|
||||||
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
|
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or
|
|||||||
Generate an invoice and pay it with any Lightning wallet.
|
Generate an invoice and pay it with any Lightning wallet.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://api.routstr.com/lightning/invoice \
|
curl -X POST https://api.routstr.com/v2/lightning/invoice \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"amount_sats": 1000, "purpose": "create"}'
|
-d '{"amount_sats": 1000, "purpose": "create"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.*
|
*Returns an invoice (`bolt11`) and an ID. Once paid, `GET /v2/lightning/invoice/{invoice_id}/status` returns your `api_key`.*
|
||||||
|
|
||||||
**Option B: Cashu Token (Best for privacy & devs)**
|
**Option B: Cashu Token (Best for privacy & devs)**
|
||||||
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
|
If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ To start making requests, you must create a "Balance" (represented by an API Key
|
|||||||
**Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
|
**Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
|
||||||
|
|
||||||
1. **Request Invoice**:
|
1. **Request Invoice**:
|
||||||
`POST /lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`.
|
`POST /v2/lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`.
|
||||||
2. **Pay Invoice**: User scans and pays the QR code/bolt11 string.
|
2. **Pay Invoice**: User scans and pays the QR code/bolt11 string.
|
||||||
3. **Receive Key**: Routstr detects the payment and issues a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats).
|
3. **Receive Key**: Poll `GET /v2/lightning/invoice/{invoice_id}/status`. Routstr detects the payment and returns a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats).
|
||||||
|
|
||||||
|
If the invoice ID is lost, recover its status with `POST /v2/lightning/recover` and `{"bolt11": "..."}`.
|
||||||
|
|
||||||
### Method B: Cashu Token Import
|
### Method B: Cashu Token Import
|
||||||
|
|
||||||
@@ -53,10 +55,10 @@ If your balance runs low, you don't need a new key. You can top up the existing
|
|||||||
|
|
||||||
### Via Lightning
|
### Via Lightning
|
||||||
|
|
||||||
`POST /lightning/invoice` with `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`.
|
`POST /v2/lightning/invoice` with `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`.
|
||||||
*Once paid, the funds are added to your existing key.*
|
*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.
|
The v2 endpoints return typed errors with stable `type` and `code` fields. The compatibility endpoints `/lightning/*` and `/v1/balance/lightning/*` remain available with their original status codes and string `detail` errors. The deprecated `api_key` request field is still accepted as a fallback, but new integrations should use v2 with the `Authorization` header.
|
||||||
|
|
||||||
### Via Cashu
|
### Via Cashu
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ from ..auth import (
|
|||||||
periodic_stale_reservation_sweep,
|
periodic_stale_reservation_sweep,
|
||||||
)
|
)
|
||||||
from ..balance import balance_router, deprecated_wallet_router
|
from ..balance import balance_router, deprecated_wallet_router
|
||||||
from ..lightning import lightning_router, periodic_invoice_watcher
|
from ..lightning import (
|
||||||
|
lightning_router,
|
||||||
|
periodic_invoice_watcher,
|
||||||
|
v2_lightning_router,
|
||||||
|
)
|
||||||
from ..nostr import (
|
from ..nostr import (
|
||||||
announce_provider,
|
announce_provider,
|
||||||
providers_cache_refresher,
|
providers_cache_refresher,
|
||||||
@@ -422,6 +426,7 @@ app.include_router(models_router)
|
|||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(balance_router)
|
app.include_router(balance_router)
|
||||||
app.include_router(lightning_router)
|
app.include_router(lightning_router)
|
||||||
|
app.include_router(v2_lightning_router)
|
||||||
app.include_router(deprecated_wallet_router)
|
app.include_router(deprecated_wallet_router)
|
||||||
app.include_router(providers_router)
|
app.include_router(providers_router)
|
||||||
app.include_router(proxy_router)
|
app.include_router(proxy_router)
|
||||||
|
|||||||
+60
-27
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
from cashu.core.base import MintQuoteState
|
from cashu.core.base import MintQuoteState
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm.attributes import set_committed_value
|
from sqlalchemy.orm.attributes import set_committed_value
|
||||||
from sqlmodel import col, select, update
|
from sqlmodel import col, select, update
|
||||||
@@ -24,6 +24,7 @@ from .core.db import (
|
|||||||
from .core.logging import get_logger
|
from .core.logging import get_logger
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
from .mint import (
|
from .mint import (
|
||||||
|
MintCooldownError,
|
||||||
is_mint_rate_limited,
|
is_mint_rate_limited,
|
||||||
mint_cooldown_remaining,
|
mint_cooldown_remaining,
|
||||||
run_mint_operation,
|
run_mint_operation,
|
||||||
@@ -38,6 +39,8 @@ from .wallet import (
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
lightning_router = APIRouter(prefix="/lightning")
|
lightning_router = APIRouter(prefix="/lightning")
|
||||||
|
v2_lightning_router = APIRouter(prefix="/v2/lightning")
|
||||||
|
|
||||||
|
|
||||||
# Avoid duplicate work within one process. Cross-process settlement is fenced
|
# Avoid duplicate work within one process. Cross-process settlement is fenced
|
||||||
# by claiming a paid quote before minting and by the final conditional update.
|
# by claiming a paid quote before minting and by the final conditional update.
|
||||||
@@ -201,6 +204,7 @@ async def _request_mint_with_fallback(
|
|||||||
candidates = trusted
|
candidates = trusted
|
||||||
else:
|
else:
|
||||||
candidates = trusted
|
candidates = trusted
|
||||||
|
all_rate_limited = bool(candidates)
|
||||||
for mint_url in candidates:
|
for mint_url in candidates:
|
||||||
cooldown = mint_cooldown_remaining(mint_url)
|
cooldown = mint_cooldown_remaining(mint_url)
|
||||||
if cooldown > 0:
|
if cooldown > 0:
|
||||||
@@ -225,8 +229,11 @@ async def _request_mint_with_fallback(
|
|||||||
return quote.request, quote.quote, mint_url
|
return quote.request, quote.quote, mint_url
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tried.append(f"{mint_url}: {type(e).__name__}")
|
tried.append(f"{mint_url}: {type(e).__name__}")
|
||||||
if not is_mint_connection_error(e) and not is_mint_rate_limited(e):
|
rate_limited = is_mint_rate_limited(e)
|
||||||
|
if not is_mint_connection_error(e) and not rate_limited:
|
||||||
raise
|
raise
|
||||||
|
if not rate_limited:
|
||||||
|
all_rate_limited = False
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"request_mint failed, trying fallback mint",
|
"request_mint failed, trying fallback mint",
|
||||||
extra={
|
extra={
|
||||||
@@ -236,6 +243,11 @@ async def _request_mint_with_fallback(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if all_rate_limited:
|
||||||
|
retry_after = max(
|
||||||
|
(mint_cooldown_remaining(mint) for mint in candidates), default=0.0
|
||||||
|
)
|
||||||
|
raise MintCooldownError("all configured mints", retry_after)
|
||||||
raise MintConnectionError(f"All mints failed for request_mint: {tried}")
|
raise MintConnectionError(f"All mints failed for request_mint: {tried}")
|
||||||
|
|
||||||
|
|
||||||
@@ -255,24 +267,41 @@ def generate_invoice_id() -> str:
|
|||||||
return secrets.token_urlsafe(16)
|
return secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
|
||||||
|
def _uses_v2_errors(request: Request) -> bool:
|
||||||
|
return request.scope["path"].startswith("/v2/lightning/")
|
||||||
|
|
||||||
|
|
||||||
def _invoice_error(
|
def _invoice_error(
|
||||||
status_code: int, message: str, error_type: str, code: str
|
status_code: int,
|
||||||
|
message: str,
|
||||||
|
error_type: str,
|
||||||
|
code: str,
|
||||||
|
*,
|
||||||
|
structured: bool,
|
||||||
|
legacy_message: str | None = None,
|
||||||
) -> HTTPException:
|
) -> HTTPException:
|
||||||
"""Build a client-facing error using the same envelope as the proxy paths."""
|
"""Build either the legacy string detail or the v2 typed envelope."""
|
||||||
return HTTPException(
|
detail: str | dict[str, dict[str, str]]
|
||||||
status_code=status_code,
|
if structured:
|
||||||
detail={"error": {"message": message, "type": error_type, "code": code}},
|
detail = {"error": {"message": message, "type": error_type, "code": code}}
|
||||||
)
|
else:
|
||||||
|
detail = legacy_message or message
|
||||||
|
return HTTPException(status_code=status_code, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
def _invoice_creation_error(error: Exception) -> HTTPException:
|
def _invoice_creation_error(error: Exception, *, structured: bool) -> HTTPException:
|
||||||
"""Map an invoice-creation failure to its specific client-facing error."""
|
"""Map an invoice-creation failure without changing legacy endpoint behavior."""
|
||||||
|
if not structured:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=500, detail="Failed to create Lightning invoice"
|
||||||
|
)
|
||||||
if is_mint_rate_limited(error):
|
if is_mint_rate_limited(error):
|
||||||
return _invoice_error(
|
return _invoice_error(
|
||||||
503,
|
503,
|
||||||
"Cashu mint rate-limited; retry after cooldown",
|
"Cashu mint rate-limited; retry after cooldown",
|
||||||
"mint_rate_limited",
|
"mint_rate_limited",
|
||||||
"lightning_mint_rate_limited",
|
"lightning_mint_rate_limited",
|
||||||
|
structured=True,
|
||||||
)
|
)
|
||||||
if is_mint_connection_error(error):
|
if is_mint_connection_error(error):
|
||||||
return _invoice_error(
|
return _invoice_error(
|
||||||
@@ -280,28 +309,26 @@ def _invoice_creation_error(error: Exception) -> HTTPException:
|
|||||||
"Cashu mint is unreachable; no Lightning quote could be requested",
|
"Cashu mint is unreachable; no Lightning quote could be requested",
|
||||||
"mint_unreachable",
|
"mint_unreachable",
|
||||||
"lightning_mint_unreachable",
|
"lightning_mint_unreachable",
|
||||||
)
|
structured=True,
|
||||||
if isinstance(error, ValueError):
|
|
||||||
return _invoice_error(
|
|
||||||
400,
|
|
||||||
"Invalid invoice amount",
|
|
||||||
"invalid_request_error",
|
|
||||||
"invalid_invoice_amount",
|
|
||||||
)
|
)
|
||||||
return _invoice_error(
|
return _invoice_error(
|
||||||
500,
|
500,
|
||||||
"Failed to create Lightning invoice",
|
"Failed to create Lightning invoice",
|
||||||
"api_error",
|
"api_error",
|
||||||
"invoice_creation_failed",
|
"invoice_creation_failed",
|
||||||
|
structured=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||||
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
@lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
request: InvoiceCreateRequest,
|
request: InvoiceCreateRequest,
|
||||||
authorization: str | None = Header(default=None),
|
authorization: str | None = Header(default=None),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
structured_errors: bool = Depends(_uses_v2_errors),
|
||||||
) -> InvoiceCreateResponse:
|
) -> InvoiceCreateResponse:
|
||||||
|
structured_errors = structured_errors is True
|
||||||
api_key_token = _extract_bearer_api_key(authorization) or request.api_key
|
api_key_token = _extract_bearer_api_key(authorization) or request.api_key
|
||||||
topup_api_key: ApiKey | None = None
|
topup_api_key: ApiKey | None = None
|
||||||
|
|
||||||
@@ -312,6 +339,7 @@ async def create_invoice(
|
|||||||
"Authorization bearer api key is required for topup",
|
"Authorization bearer api key is required for topup",
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
"topup_authorization_required",
|
"topup_authorization_required",
|
||||||
|
structured=structured_errors,
|
||||||
)
|
)
|
||||||
if not api_key_token.startswith("sk-"):
|
if not api_key_token.startswith("sk-"):
|
||||||
raise _invoice_error(
|
raise _invoice_error(
|
||||||
@@ -319,6 +347,8 @@ async def create_invoice(
|
|||||||
"Invalid API key format. Expected an 'sk-...' API key.",
|
"Invalid API key format. Expected an 'sk-...' API key.",
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
"topup_invalid_api_key_format",
|
"topup_invalid_api_key_format",
|
||||||
|
structured=structured_errors,
|
||||||
|
legacy_message="Invalid API key format",
|
||||||
)
|
)
|
||||||
|
|
||||||
topup_api_key = await session.get(ApiKey, api_key_token[3:])
|
topup_api_key = await session.get(ApiKey, api_key_token[3:])
|
||||||
@@ -328,6 +358,7 @@ async def create_invoice(
|
|||||||
"API key not found",
|
"API key not found",
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
"topup_api_key_not_found",
|
"topup_api_key_not_found",
|
||||||
|
structured=structured_errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -338,9 +369,7 @@ async def create_invoice(
|
|||||||
# A key's liabilities are attributed to a single refund mint. Keep
|
# A key's liabilities are attributed to a single refund mint. Keep
|
||||||
# top-up collateral on that same mint so balances and payouts cannot
|
# top-up collateral on that same mint so balances and payouts cannot
|
||||||
# misclassify funds held by another mint as owner profit.
|
# misclassify funds held by another mint as owner profit.
|
||||||
allowed_mints = [
|
allowed_mints = [topup_api_key.refund_mint_url or settings.primary_mint]
|
||||||
topup_api_key.refund_mint_url or settings.primary_mint
|
|
||||||
]
|
|
||||||
bolt11, payment_hash, mint_url = await generate_lightning_invoice(
|
bolt11, payment_hash, mint_url = await generate_lightning_invoice(
|
||||||
request.amount_sats, description, allowed_mints=allowed_mints
|
request.amount_sats, description, allowed_mints=allowed_mints
|
||||||
)
|
)
|
||||||
@@ -387,15 +416,19 @@ async def create_invoice(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create Lightning invoice: {e}")
|
logger.error(f"Failed to create Lightning invoice: {e}")
|
||||||
raise _invoice_creation_error(e)
|
raise _invoice_creation_error(e, structured=structured_errors)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_lightning_router.get(
|
||||||
|
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
||||||
|
)
|
||||||
@lightning_router.get(
|
@lightning_router.get(
|
||||||
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
|
||||||
)
|
)
|
||||||
async def get_invoice_status(
|
async def get_invoice_status(
|
||||||
invoice_id: str,
|
invoice_id: str,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
structured_errors: bool = Depends(_uses_v2_errors),
|
||||||
) -> InvoiceStatusResponse:
|
) -> InvoiceStatusResponse:
|
||||||
invoice = await session.get(LightningInvoice, invoice_id)
|
invoice = await session.get(LightningInvoice, invoice_id)
|
||||||
if not invoice:
|
if not invoice:
|
||||||
@@ -404,6 +437,7 @@ async def get_invoice_status(
|
|||||||
"Invoice not found",
|
"Invoice not found",
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
"invoice_not_found",
|
"invoice_not_found",
|
||||||
|
structured=structured_errors is True,
|
||||||
)
|
)
|
||||||
|
|
||||||
definitively_unpaid = False
|
definitively_unpaid = False
|
||||||
@@ -432,10 +466,12 @@ async def get_invoice_status(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@v2_lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
||||||
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
@lightning_router.post("/recover", response_model=InvoiceStatusResponse)
|
||||||
async def recover_invoice(
|
async def recover_invoice(
|
||||||
request: InvoiceRecoverRequest,
|
request: InvoiceRecoverRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
structured_errors: bool = Depends(_uses_v2_errors),
|
||||||
) -> InvoiceStatusResponse:
|
) -> InvoiceStatusResponse:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
|
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
|
||||||
@@ -448,6 +484,7 @@ async def recover_invoice(
|
|||||||
"Invoice not found",
|
"Invoice not found",
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
"invoice_not_found",
|
"invoice_not_found",
|
||||||
|
structured=structured_errors is True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Recovery is the last remedy for a payment we never observed, so it ignores
|
# Recovery is the last remedy for a payment we never observed, so it ignores
|
||||||
@@ -614,9 +651,7 @@ async def check_invoice_payment(
|
|||||||
"invoice_id": settlement.id,
|
"invoice_id": settlement.id,
|
||||||
"amount_sats": settlement.amount_sats,
|
"amount_sats": settlement.amount_sats,
|
||||||
"purpose": settlement.purpose,
|
"purpose": settlement.purpose,
|
||||||
"api_key_hash": api_key_hash[:8] + "..."
|
"api_key_hash": api_key_hash[:8] + "..." if api_key_hash else None,
|
||||||
if api_key_hash
|
|
||||||
else None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
@@ -638,9 +673,7 @@ async def check_invoice_payment(
|
|||||||
)
|
)
|
||||||
await state_session.commit()
|
await state_session.commit()
|
||||||
if pending.rowcount == 1:
|
if pending.rowcount == 1:
|
||||||
_publish_invoice_value(
|
_publish_invoice_value(invoice, "status", "settlement_pending")
|
||||||
invoice, "status", "settlement_pending"
|
|
||||||
)
|
|
||||||
except Exception as state_error:
|
except Exception as state_error:
|
||||||
logger.critical(
|
logger.critical(
|
||||||
"Paid invoice reconciliation state could not be persisted",
|
"Paid invoice reconciliation state could not be persisted",
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
"""RIP-08 lightning invoice endpoint tests.
|
"""Lightning invoice endpoint compatibility and v2 contract 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 __future__ import annotations
|
||||||
|
|
||||||
@@ -20,6 +15,9 @@ from routstr.wallet import MintConnectionError
|
|||||||
|
|
||||||
RIP08_PATH = "/lightning/invoice"
|
RIP08_PATH = "/lightning/invoice"
|
||||||
LEGACY_PATH = "/v1/balance/lightning/invoice"
|
LEGACY_PATH = "/v1/balance/lightning/invoice"
|
||||||
|
V2_PATH = "/v2/lightning/invoice"
|
||||||
|
COMPATIBILITY_PATHS = [RIP08_PATH, LEGACY_PATH]
|
||||||
|
ALL_PATHS = [*COMPATIBILITY_PATHS, V2_PATH]
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -64,13 +62,13 @@ async def seeded_topup_key(integration_session: AsyncSession) -> str:
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
@pytest.mark.parametrize("path", ALL_PATHS)
|
||||||
async def test_create_invoice_purpose_create(
|
async def test_create_invoice_purpose_create(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
patch_invoice_generation: Any,
|
patch_invoice_generation: Any,
|
||||||
path: str,
|
path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""`purpose=create` works on both paths and requires no auth."""
|
"""`purpose=create` works on every path and requires no auth."""
|
||||||
resp = await integration_client.post(
|
resp = await integration_client.post(
|
||||||
path,
|
path,
|
||||||
json={"amount_sats": 1000, "purpose": "create"},
|
json={"amount_sats": 1000, "purpose": "create"},
|
||||||
@@ -85,7 +83,7 @@ async def test_create_invoice_purpose_create(
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
@pytest.mark.parametrize("path", ALL_PATHS)
|
||||||
async def test_topup_with_authorization_header(
|
async def test_topup_with_authorization_header(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
patch_invoice_generation: Any,
|
patch_invoice_generation: Any,
|
||||||
@@ -108,14 +106,14 @@ async def test_topup_with_authorization_header(
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
@pytest.mark.parametrize("path", ALL_PATHS)
|
||||||
async def test_topup_with_legacy_api_key_in_body(
|
async def test_topup_with_legacy_api_key_in_body(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
patch_invoice_generation: Any,
|
patch_invoice_generation: Any,
|
||||||
seeded_topup_key: str,
|
seeded_topup_key: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Legacy: topup with `api_key` in body still accepted on both paths."""
|
"""The deprecated body `api_key` remains accepted on every path."""
|
||||||
resp = await integration_client.post(
|
resp = await integration_client.post(
|
||||||
path,
|
path,
|
||||||
json={
|
json={
|
||||||
@@ -130,18 +128,33 @@ async def test_topup_with_legacy_api_key_in_body(
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
@pytest.mark.parametrize("path", COMPATIBILITY_PATHS)
|
||||||
async def test_topup_missing_auth_returns_401(
|
async def test_compatibility_topup_missing_auth_keeps_string_error(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
patch_invoice_generation: Any,
|
patch_invoice_generation: Any,
|
||||||
path: str,
|
path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Topup without any credential is rejected on both paths."""
|
|
||||||
resp = await integration_client.post(
|
resp = await integration_client.post(
|
||||||
path,
|
path,
|
||||||
json={"amount_sats": 100, "purpose": "topup"},
|
json={"amount_sats": 100, "purpose": "topup"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["detail"] == (
|
||||||
|
"Authorization bearer api key is required for topup"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v2_topup_missing_auth_returns_typed_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
patch_invoice_generation: Any,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.post(
|
||||||
|
V2_PATH,
|
||||||
|
json={"amount_sats": 100, "purpose": "topup"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
error = resp.json()["detail"]["error"]
|
error = resp.json()["detail"]["error"]
|
||||||
assert error["type"] == "invalid_request_error"
|
assert error["type"] == "invalid_request_error"
|
||||||
assert error["code"] == "topup_authorization_required"
|
assert error["code"] == "topup_authorization_required"
|
||||||
@@ -149,8 +162,47 @@ async def test_topup_missing_auth_returns_401(
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
@pytest.mark.parametrize(
|
||||||
async def test_topup_unknown_api_key_returns_404(
|
"path,expected_detail",
|
||||||
|
[
|
||||||
|
(RIP08_PATH, "Invalid API key format"),
|
||||||
|
(LEGACY_PATH, "Invalid API key format"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_compatibility_invalid_api_key_format_keeps_original_message(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
path: str,
|
||||||
|
expected_detail: str,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.post(
|
||||||
|
path,
|
||||||
|
json={"amount_sats": 100, "purpose": "topup"},
|
||||||
|
headers={"Authorization": "Bearer invalid"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == expected_detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v2_invalid_api_key_format_returns_typed_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.post(
|
||||||
|
V2_PATH,
|
||||||
|
json={"amount_sats": 100, "purpose": "topup"},
|
||||||
|
headers={"Authorization": "Bearer invalid"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
error = resp.json()["detail"]["error"]
|
||||||
|
assert error["type"] == "invalid_request_error"
|
||||||
|
assert error["code"] == "topup_invalid_api_key_format"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("path", COMPATIBILITY_PATHS)
|
||||||
|
async def test_compatibility_unknown_api_key_keeps_string_error(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
patch_invoice_generation: Any,
|
patch_invoice_generation: Any,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -161,6 +213,21 @@ async def test_topup_unknown_api_key_returns_404(
|
|||||||
headers={"Authorization": "Bearer sk-deadbeef"},
|
headers={"Authorization": "Bearer sk-deadbeef"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"] == "API key not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v2_unknown_api_key_returns_typed_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
patch_invoice_generation: Any,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.post(
|
||||||
|
V2_PATH,
|
||||||
|
json={"amount_sats": 100, "purpose": "topup"},
|
||||||
|
headers={"Authorization": "Bearer sk-deadbeef"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
error = resp.json()["detail"]["error"]
|
error = resp.json()["detail"]["error"]
|
||||||
assert error["type"] == "invalid_request_error"
|
assert error["type"] == "invalid_request_error"
|
||||||
assert error["code"] == "topup_api_key_not_found"
|
assert error["code"] == "topup_api_key_not_found"
|
||||||
@@ -168,13 +235,22 @@ async def test_topup_unknown_api_key_returns_404(
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
@pytest.mark.parametrize("path", COMPATIBILITY_PATHS)
|
||||||
async def test_invoice_status_404_for_unknown_id(
|
async def test_compatibility_status_404_keeps_string_error(
|
||||||
integration_client: AsyncClient,
|
integration_client: AsyncClient,
|
||||||
path: str,
|
path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
base = path.rsplit("/invoice", 1)[0] + "/invoice"
|
resp = await integration_client.get(f"{path}/does-not-exist/status")
|
||||||
resp = await integration_client.get(f"{base}/does-not-exist/status")
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"] == "Invoice not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v2_status_404_returns_typed_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.get(f"{V2_PATH}/does-not-exist/status")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
error = resp.json()["detail"]["error"]
|
error = resp.json()["detail"]["error"]
|
||||||
assert error["type"] == "invalid_request_error"
|
assert error["type"] == "invalid_request_error"
|
||||||
@@ -235,7 +311,53 @@ async def test_create_invoice_maps_mint_failures(
|
|||||||
"routstr.lightning.generate_lightning_invoice",
|
"routstr.lightning.generate_lightning_invoice",
|
||||||
side_effect=error,
|
side_effect=error,
|
||||||
):
|
):
|
||||||
resp = await integration_client.post(RIP08_PATH, json={"amount_sats": 100})
|
resp = await integration_client.post(V2_PATH, json={"amount_sats": 100})
|
||||||
|
|
||||||
assert resp.status_code == status
|
assert resp.status_code == status
|
||||||
assert resp.json()["detail"]["error"]["code"] == code
|
assert resp.json()["detail"]["error"]["code"] == code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compatibility_create_failure_keeps_generic_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
with patch(
|
||||||
|
"routstr.lightning.generate_lightning_invoice",
|
||||||
|
side_effect=MintConnectionError("all mints failed"),
|
||||||
|
):
|
||||||
|
resp = await integration_client.post(RIP08_PATH, json={"amount_sats": 100})
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert resp.json()["detail"] == "Failed to create Lightning invoice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected_detail",
|
||||||
|
[
|
||||||
|
("/lightning/recover", "Invoice not found"),
|
||||||
|
("/v1/balance/lightning/recover", "Invoice not found"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_compatibility_recover_404_keeps_string_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
path: str,
|
||||||
|
expected_detail: str,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.post(path, json={"bolt11": "unknown"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"] == expected_detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_v2_recover_404_returns_typed_error(
|
||||||
|
integration_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
resp = await integration_client.post(
|
||||||
|
"/v2/lightning/recover", json={"bolt11": "unknown"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"]["error"]["code"] == "invoice_not_found"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from routstr.core.settings import settings
|
from routstr.core.settings import settings
|
||||||
from routstr.lightning import _request_mint_with_fallback
|
from routstr.lightning import _request_mint_with_fallback
|
||||||
|
from routstr.mint import MintCooldownError, is_mint_rate_limited
|
||||||
|
|
||||||
TRUSTED = "https://good-mint.example.com"
|
TRUSTED = "https://good-mint.example.com"
|
||||||
UNTRUSTED = "https://removed-mint.example.com"
|
UNTRUSTED = "https://removed-mint.example.com"
|
||||||
@@ -48,3 +49,15 @@ async def test_trusted_allowed_mints_are_used_verbatim() -> None:
|
|||||||
await _request_mint_with_fallback(10, allowed_mints=[TRUSTED])
|
await _request_mint_with_fallback(10, allowed_mints=[TRUSTED])
|
||||||
|
|
||||||
assert attempted == [TRUSTED]
|
assert attempted == [TRUSTED]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_cooling_down_mints_preserve_rate_limit_error() -> None:
|
||||||
|
with (
|
||||||
|
patch.object(settings, "primary_mint", TRUSTED),
|
||||||
|
patch.object(settings, "cashu_mints", [TRUSTED]),
|
||||||
|
patch("routstr.lightning.mint_cooldown_remaining", return_value=30.0),
|
||||||
|
):
|
||||||
|
with pytest.raises(MintCooldownError) as caught:
|
||||||
|
await _request_mint_with_fallback(10)
|
||||||
|
|
||||||
|
assert is_mint_rate_limited(caught.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user