From 795fff61e007139f86987114d005a6089d294ebf Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 30 Jan 2026 21:55:57 +0100 Subject: [PATCH 01/10] child-key-expiration --- routstr/auth.py | 60 +++++++ routstr/balance.py | 40 +++++ routstr/core/admin.py | 39 +++++ routstr/core/db.py | 16 +- ui/components/child-key-creator.tsx | 232 +++++++++++++++++++++++++--- ui/lib/api/services/wallet.ts | 52 ++++++- 6 files changed, 412 insertions(+), 27 deletions(-) diff --git a/routstr/auth.py b/routstr/auth.py index 1f869886..596e2087 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -349,6 +349,66 @@ async def pay_for_request( }, ) + # Check balance limit for child keys (or any key with a limit) + if ( + key.balance_limit is not None + and key.total_spent + cost_per_request > key.balance_limit + ): + logger.warning( + "Balance limit exceeded", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "total_spent": key.total_spent, + "balance_limit": key.balance_limit, + "required": cost_per_request, + }, + ) + raise HTTPException( + status_code=402, + detail={ + "error": { + "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", + "type": "insufficient_quota", + "code": "balance_limit_exceeded", + } + }, + ) + + # Check validity date + if key.validity_date is not None: + import time + + if time.time() > key.validity_date: + logger.warning( + "Key validity date expired", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "validity_date": key.validity_date, + "current_time": time.time(), + }, + ) + raise HTTPException( + status_code=403, + detail={ + "error": { + "message": "API key has expired (validity date reached).", + "type": "invalid_request_error", + "code": "key_expired", + } + }, + ) + + raise HTTPException( + status_code=402, + detail={ + "error": { + "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", + "type": "insufficient_quota", + "code": "balance_limit_exceeded", + } + }, + ) + logger.debug( "Charging base cost for request", extra={ diff --git a/routstr/balance.py b/routstr/balance.py index 1a1e5850..4f1ab7be 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -44,6 +44,9 @@ async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict: "parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None, "total_requests": key.total_requests, "total_spent": key.total_spent, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, } @@ -253,6 +256,9 @@ async def donate(token: str, ref: str | None = None) -> str: class ChildKeyRequest(BaseModel): count: int + balance_limit: int | None = None + balance_limit_reset: str | None = None + validity_date: int | None = None @router.post("/child-key") @@ -302,6 +308,9 @@ async def create_child_key( hashed_key=new_key_hash, balance=0, parent_key_hash=key.hashed_key, + balance_limit=payload.balance_limit, + balance_limit_reset=payload.balance_limit_reset, + validity_date=payload.validity_date, ) session.add(child_key) new_keys.append("sk-" + new_key_hash) @@ -320,6 +329,37 @@ async def create_child_key( return response_data +class ChildKeyResetRequest(BaseModel): + child_key: str + + +@router.post("/child-key/reset") +async def reset_child_key_spent( + payload: ChildKeyResetRequest, + key: ApiKey = Depends(get_key_from_header), + session: AsyncSession = Depends(get_session), +) -> dict: + """Resets the total_spent of a child key. Must be called by the parent.""" + child_key_raw = payload.child_key + if child_key_raw.startswith("sk-"): + child_key_raw = child_key_raw[3:] + + child_key = await session.get(ApiKey, child_key_raw) + if not child_key: + raise HTTPException(status_code=404, detail="Child key not found.") + + if child_key.parent_key_hash != key.hashed_key: + raise HTTPException( + status_code=403, detail="Unauthorized. You are not the parent of this key." + ) + + child_key.total_spent = 0 + session.add(child_key) + await session.commit() + + return {"success": True, "message": "Child key balance reset successfully."} + + @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 783474a8..dfd15e21 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -159,11 +159,50 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object] "refund_address": key.refund_address, "key_expiry_time": key.key_expiry_time, "parent_key_hash": key.parent_key_hash, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, } for key in api_keys ] +class ApiKeyUpdate(BaseModel): + balance_limit: int | None = None + balance_limit_reset: str | None = None + validity_date: int | None = None + + +@admin_router.patch( + "/api/apikeys/{hashed_key}", dependencies=[Depends(require_admin_api)] +) +async def update_apikey( + request: Request, hashed_key: str, update: ApiKeyUpdate +) -> dict: + async with create_session() as session: + key = await session.get(ApiKey, hashed_key) + if not key: + raise HTTPException(status_code=404, detail="API key not found") + + if update.balance_limit is not None: + key.balance_limit = update.balance_limit + if update.balance_limit_reset is not None: + key.balance_limit_reset = update.balance_limit_reset + if update.validity_date is not None: + key.validity_date = update.validity_date + + session.add(key) + await session.commit() + await session.refresh(key) + + return { + "hashed_key": key.hashed_key, + "balance_limit": key.balance_limit, + "balance_limit_reset": key.balance_limit_reset, + "validity_date": key.validity_date, + } + + @admin_router.get("/api/balances", dependencies=[Depends(require_admin_api)]) async def get_balances_api(request: Request) -> list[dict[str, object]]: balance_details, _tw, _tu, _ow = await fetch_all_balances() diff --git a/routstr/core/db.py b/routstr/core/db.py index bbcfc6fe..d2a3491d 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -51,6 +51,18 @@ class ApiKey(SQLModel, table=True): # type: ignore parent_key_hash: str | None = Field( default=None, foreign_key="api_keys.hashed_key", index=True ) + balance_limit: int | None = Field( + default=None, + description="Max spendable balance in msats for this key (mostly for child keys)", + ) + balance_limit_reset: str | None = Field( + default=None, + description="Reset policy for balance limit (manual, daily, monthly, etc.)", + ) + validity_date: int | None = Field( + default=None, + description="Unix timestamp after which the key is no longer valid", + ) @property def total_balance(self) -> int: @@ -113,7 +125,9 @@ class LightningInvoice(SQLModel, table=True): # type: ignore class UpstreamProviderRow(SQLModel, table=True): # type: ignore __tablename__ = "upstream_providers" __table_args__ = ( - UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"), + UniqueConstraint( + "base_url", "api_key", name="uq_upstream_providers_base_url_api_key" + ), ) id: int | None = Field(default=None, primary_key=True) provider_type: str = Field( diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 65861731..56fa5b4b 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/card'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; -import { Key, Copy, Check, Loader2 } from 'lucide-react'; +import { Key, Copy, Check, Loader2, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; interface ChildKeyCreatorProps { @@ -31,6 +31,18 @@ export function ChildKeyCreator({ const [internalApiKey, setInternalApiKey] = useState(''); const [loading, setLoading] = useState(false); const [count, setCount] = useState(1); + const [balanceLimit, setBalanceLimit] = useState(''); + const [balanceLimitReset, setBalanceLimitReset] = useState(''); + const [validityDate, setValidityDate] = useState(''); + const [childKeyToCheck, setChildKeyToCheck] = useState(''); + const [checking, setChecking] = useState(false); + const [keyStatus, setKeyStatus] = useState<{ + total_spent: number; + balance_limit: number | null; + validity_date: number | null; + is_expired: boolean; + is_drained: boolean; + } | null>(null); const [newKeys, setNewKeys] = useState([]); const [resultInfo, setResultInfo] = useState<{ cost_msats: number; @@ -58,7 +70,12 @@ export function ChildKeyCreator({ const result = await WalletService.createChildKey( baseUrl, activeApiKey, - requestedCount + requestedCount, + balanceLimit ? parseInt(balanceLimit) : undefined, + balanceLimitReset || undefined, + validityDate + ? Math.floor(new Date(validityDate + 'T23:59:59').getTime() / 1000) + : undefined ); console.log('Created child keys:', result); @@ -89,6 +106,47 @@ export function ChildKeyCreator({ } }; + const handleCheckKey = async () => { + if (!childKeyToCheck) { + toast.error('Please provide a Child API key to check'); + return; + } + + setChecking(true); + setKeyStatus(null); + try { + const baseUrlToUse = baseUrl || ''; + const response = await fetch(`${baseUrlToUse}/v1/balance/info`, { + headers: { + Authorization: `Bearer ${childKeyToCheck}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch key info'); + } + + const info = await response.json(); + const now = Math.floor(Date.now() / 1000); + + setKeyStatus({ + total_spent: info.total_spent, + balance_limit: info.balance_limit, + validity_date: info.validity_date, + is_expired: info.validity_date ? now > info.validity_date : false, + is_drained: info.balance_limit + ? info.total_spent >= info.balance_limit + : false, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to check child key' + ); + } finally { + setChecking(false); + } + }; + const copyToClipboard = (key: string) => { navigator.clipboard.writeText(key); setCopiedKey(key); @@ -152,27 +210,70 @@ export function ChildKeyCreator({ )} - { - const val = parseInt(e.target.value); - if (!isNaN(val)) { - setCount(Math.max(1, Math.min(50, val))); - } else { - setCount(1); - } - }} - className='w-full sm:w-24' - /> - - + + {keyStatus && ( +
+
+ Total Spent: + + {keyStatus.total_spent} mSats + +
+ {keyStatus.balance_limit !== null && ( +
+ Limit: + + {keyStatus.balance_limit} mSats + +
+ )} + {keyStatus.validity_date !== null && ( +
+ Expires: + + {new Date( + keyStatus.validity_date * 1000 + ).toLocaleDateString()} + +
+ )} +
+ {keyStatus.is_drained && ( + Drained + )} + {keyStatus.is_expired && ( + Expired + )} + {!keyStatus.is_drained && !keyStatus.is_expired && ( + + Active + + )} +
+
+ )} + + + ); } diff --git a/ui/lib/api/services/wallet.ts b/ui/lib/api/services/wallet.ts index 8e06a9b9..d16da3ac 100644 --- a/ui/lib/api/services/wallet.ts +++ b/ui/lib/api/services/wallet.ts @@ -134,7 +134,10 @@ export class WalletService { static async createChildKey( baseUrl?: string, apiKey?: string, - count: number = 1 + count: number = 1, + balanceLimit?: number, + balanceLimitReset?: string, + validityDate?: number ): Promise { try { if (baseUrl && apiKey) { @@ -144,7 +147,12 @@ export class WalletService { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ count }), + body: JSON.stringify({ + count, + balance_limit: balanceLimit, + balance_limit_reset: balanceLimitReset, + validity_date: validityDate, + }), }); if (!response.ok) { @@ -157,11 +165,49 @@ export class WalletService { return await apiClient.post( '/v1/balance/child-key', - { count } + { + count, + balance_limit: balanceLimit, + balance_limit_reset: balanceLimitReset, + validity_date: validityDate, + } ); } catch (error) { console.error('Error creating child key:', error); throw error; } } + + static async resetChildKeySpent( + baseUrl: string | undefined, + parentKey: string, + childKey: string + ): Promise<{ success: boolean; message: string }> { + try { + const url = baseUrl + ? `${baseUrl}/v1/balance/child-key/reset` + : '/v1/balance/child-key/reset'; + + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${parentKey}`, + }; + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ child_key: childKey }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to reset child key'); + } + + return await response.json(); + } catch (error) { + console.error('Error resetting child key:', error); + throw error; + } + } } From ba5f9fc181664f8e2d43c500e709f704f4f9a889 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 4 Feb 2026 23:10:50 +0100 Subject: [PATCH 02/10] improvve key logic --- ...add_key_management_and_reset_fields_to_.py | 37 ++++ routstr/auth.py | 164 ++++++++++++++---- routstr/balance.py | 27 ++- routstr/core/db.py | 4 + routstr/core/main.py | 7 + routstr/lightning.py | 6 + tests/integration/test_key_logic.py | 141 +++++++++++++++ ui/components/child-key-creator.tsx | 106 +++++------ ui/components/key-options.tsx | 68 ++++++++ .../landing/cashu-payment-workflow.tsx | 41 +++-- .../landing/lightning-payment-workflow.tsx | 49 ++++-- 11 files changed, 526 insertions(+), 124 deletions(-) create mode 100644 migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py create mode 100644 tests/integration/test_key_logic.py create mode 100644 ui/components/key-options.tsx diff --git a/migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py b/migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py new file mode 100644 index 00000000..d40f99e0 --- /dev/null +++ b/migrations/versions/06f81c0fc88d_add_key_management_and_reset_fields_to_.py @@ -0,0 +1,37 @@ +"""add key management and reset fields to api_keys + +Revision ID: 06f81c0fc88d +Revises: c2d3e4f5a6b7 +Create Date: 2026-02-04 22:44:03.311983 +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +# revision identifiers, used by Alembic. +revision = "06f81c0fc88d" +down_revision = "c2d3e4f5a6b7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("api_keys", sa.Column("balance_limit", sa.Integer(), nullable=True)) + op.add_column( + "api_keys", + sa.Column( + "balance_limit_reset", sqlmodel.sql.sqltypes.AutoString(), nullable=True + ), + ) + op.add_column( + "api_keys", sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True) + ) + op.add_column("api_keys", sa.Column("validity_date", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("api_keys", "validity_date") + op.drop_column("api_keys", "balance_limit_reset_date") + op.drop_column("api_keys", "balance_limit_reset") + op.drop_column("api_keys", "balance_limit") diff --git a/routstr/auth.py b/routstr/auth.py index 596e2087..6afbecab 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -1,10 +1,14 @@ +import asyncio import hashlib import math +import random +import time +from datetime import datetime from typing import Optional from fastapi import HTTPException from sqlalchemy.exc import IntegrityError -from sqlmodel import col, update +from sqlmodel import col, select, update from .core import get_logger from .core.db import ApiKey, AsyncSession @@ -349,35 +353,8 @@ async def pay_for_request( }, ) - # Check balance limit for child keys (or any key with a limit) - if ( - key.balance_limit is not None - and key.total_spent + cost_per_request > key.balance_limit - ): - logger.warning( - "Balance limit exceeded", - extra={ - "key_hash": key.hashed_key[:8] + "...", - "total_spent": key.total_spent, - "balance_limit": key.balance_limit, - "required": cost_per_request, - }, - ) - raise HTTPException( - status_code=402, - detail={ - "error": { - "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", - "type": "insufficient_quota", - "code": "balance_limit_exceeded", - } - }, - ) - # Check validity date if key.validity_date is not None: - import time - if time.time() > key.validity_date: logger.warning( "Key validity date expired", @@ -398,16 +375,65 @@ async def pay_for_request( }, ) - raise HTTPException( - status_code=402, - detail={ - "error": { - "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", - "type": "insufficient_quota", - "code": "balance_limit_exceeded", - } - }, - ) + # Check balance limit for child keys (or any key with a limit) + if key.balance_limit is not None: + if key.balance_limit_reset: + now = int(time.time()) + reset_date = key.balance_limit_reset_date or 0 + should_reset = False + + if key.balance_limit_reset == "daily": + if ( + datetime.fromtimestamp(now).date() + > datetime.fromtimestamp(reset_date).date() + ): + should_reset = True + elif key.balance_limit_reset == "weekly": + if ( + datetime.fromtimestamp(now).isocalendar()[:2] + > datetime.fromtimestamp(reset_date).isocalendar()[:2] + ): + should_reset = True + elif key.balance_limit_reset == "monthly": + dt_now = datetime.fromtimestamp(now) + dt_reset = datetime.fromtimestamp(reset_date) + if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month: + should_reset = True + + if should_reset: + logger.info( + "Resetting balance limit for key", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "policy": key.balance_limit_reset, + "old_spent": key.total_spent, + }, + ) + key.total_spent = 0 + key.balance_limit_reset_date = now + session.add(key) + await session.flush() + + if key.total_spent + cost_per_request > key.balance_limit: + logger.warning( + "Balance limit exceeded", + extra={ + "key_hash": key.hashed_key[:8] + "...", + "total_spent": key.total_spent, + "balance_limit": key.balance_limit, + "required": cost_per_request, + }, + ) + raise HTTPException( + status_code=402, + detail={ + "error": { + "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent.", + "type": "insufficient_quota", + "code": "balance_limit_exceeded", + } + }, + ) logger.debug( "Charging base cost for request", @@ -875,3 +901,65 @@ async def adjust_payment_for_tokens( "output_msats": 0, "total_msats": deducted_max_cost, } + + +async def periodic_key_reset() -> None: + """Background task to reset key limits based on their policy.""" + from .core.db import create_session + + while True: + try: + interval = 3600 # Run every hour + jitter = 300 + await asyncio.sleep(interval + random.uniform(0, jitter)) + except asyncio.CancelledError: + break + + try: + async with create_session() as session: + # Find all keys that have a reset policy + stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore + keys = (await session.exec(stmt)).all() + + now = int(time.time()) + updated_count = 0 + + for key in keys: + reset_date = key.balance_limit_reset_date or 0 + should_reset = False + + if key.balance_limit_reset == "daily": + if ( + datetime.fromtimestamp(now).date() + > datetime.fromtimestamp(reset_date).date() + ): + should_reset = True + elif key.balance_limit_reset == "weekly": + if ( + datetime.fromtimestamp(now).isocalendar()[:2] + > datetime.fromtimestamp(reset_date).isocalendar()[:2] + ): + should_reset = True + elif key.balance_limit_reset == "monthly": + dt_now = datetime.fromtimestamp(now) + dt_reset = datetime.fromtimestamp(reset_date) + if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month: + should_reset = True + + if should_reset: + key.total_spent = 0 + key.balance_limit_reset_date = now + session.add(key) + updated_count += 1 + + if updated_count > 0: + await session.commit() + logger.info( + "Periodic key reset complete", + extra={"keys_reset": updated_count}, + ) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in periodic_key_reset: {e}") diff --git a/routstr/balance.py b/routstr/balance.py index 4f1ab7be..6a2c8253 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import time from time import monotonic from typing import Annotated, NoReturn @@ -73,9 +74,24 @@ async def account_info( @router.get("/create") async def create_balance( - initial_balance_token: str, session: AsyncSession = Depends(get_session) + initial_balance_token: str, + balance_limit: int | None = None, + balance_limit_reset: str | None = None, + validity_date: int | None = None, + session: AsyncSession = Depends(get_session), ) -> dict: key = await validate_bearer_key(initial_balance_token, session) + + if balance_limit is not None or balance_limit_reset or validity_date: + key.balance_limit = balance_limit + key.balance_limit_reset = balance_limit_reset + key.validity_date = validity_date + if balance_limit_reset: + key.balance_limit_reset_date = int(time.time()) + session.add(key) + await session.commit() + await session.refresh(key) + return { "api_key": "sk-" + key.hashed_key, "balance": key.balance, @@ -235,7 +251,9 @@ async def refund_wallet_endpoint( await _refund_cache_set(bearer_value, result) - await session.delete(key) + key.balance = 0 + key.reserved_balance = 0 + session.add(key) await session.commit() return result @@ -310,6 +328,9 @@ async def create_child_key( parent_key_hash=key.hashed_key, balance_limit=payload.balance_limit, balance_limit_reset=payload.balance_limit_reset, + balance_limit_reset_date=int(time.time()) + if payload.balance_limit_reset + else None, validity_date=payload.validity_date, ) session.add(child_key) @@ -354,6 +375,8 @@ async def reset_child_key_spent( ) child_key.total_spent = 0 + if child_key.balance_limit_reset: + child_key.balance_limit_reset_date = int(time.time()) session.add(child_key) await session.commit() diff --git a/routstr/core/db.py b/routstr/core/db.py index d2a3491d..71834d5a 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -59,6 +59,10 @@ class ApiKey(SQLModel, table=True): # type: ignore default=None, description="Reset policy for balance limit (manual, daily, monthly, etc.)", ) + balance_limit_reset_date: int | None = Field( + default=None, + description="Unix timestamp of the last time the balance limit was reset", + ) validity_date: int | None = Field( default=None, description="Unix timestamp after which the key is no longer valid", diff --git a/routstr/core/main.py b/routstr/core/main.py index c18eb76a..23aee372 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -10,6 +10,7 @@ from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException +from ..auth import periodic_key_reset from ..balance import balance_router, deprecated_wallet_router from ..discovery import providers_cache_refresher, providers_router from ..nip91 import announce_provider @@ -46,6 +47,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: providers_task = None models_refresh_task = None model_maps_refresh_task = None + key_reset_task = None try: # Run database migrations on startup @@ -101,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: nip91_task = asyncio.create_task(announce_provider()) if global_settings.providers_refresh_interval_seconds > 0: providers_task = asyncio.create_task(providers_cache_refresher()) + key_reset_task = asyncio.create_task(periodic_key_reset()) yield @@ -130,6 +133,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: models_refresh_task.cancel() if model_maps_refresh_task is not None: model_maps_refresh_task.cancel() + if key_reset_task is not None: + key_reset_task.cancel() try: tasks_to_wait = [] @@ -147,6 +152,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(models_refresh_task) if model_maps_refresh_task is not None: tasks_to_wait.append(model_maps_refresh_task) + if key_reset_task is not None: + tasks_to_wait.append(key_reset_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 50d1608d..aecbb48f 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -23,6 +23,9 @@ class InvoiceCreateRequest(BaseModel): api_key: str | None = Field( default=None, description="Required for topup operations" ) + balance_limit: int | None = Field(default=None) + balance_limit_reset: str | None = Field(default=None) + validity_date: int | None = Field(default=None) class InvoiceCreateResponse(BaseModel): @@ -94,6 +97,9 @@ async def create_invoice( status="pending", api_key_hash=request.api_key[3:] if request.api_key else None, purpose=request.purpose, + balance_limit=request.balance_limit, + balance_limit_reset=request.balance_limit_reset, + validity_date=request.validity_date, expires_at=expires_at, ) diff --git a/tests/integration/test_key_logic.py b/tests/integration/test_key_logic.py new file mode 100644 index 00000000..12cf4e32 --- /dev/null +++ b/tests/integration/test_key_logic.py @@ -0,0 +1,141 @@ +import time +from datetime import datetime, timedelta + +import pytest +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from routstr.auth import pay_for_request +from routstr.core.db import ApiKey + + +@pytest.mark.asyncio +async def test_key_validity_date(integration_session: AsyncSession) -> None: + # 1. Create a key that is expired + expired_time = int(time.time()) - 3600 + key = ApiKey(hashed_key="expired_key", balance=1000, validity_date=expired_time) + integration_session.add(key) + await integration_session.commit() + + # 2. Try to pay for a request - should fail + with pytest.raises(Exception) as excinfo: + await pay_for_request(key, 100, integration_session) + assert "expired" in str(excinfo.value).lower() + + +@pytest.mark.asyncio +async def test_key_balance_limit(integration_session: AsyncSession) -> None: + # 1. Create a key with a balance limit + key = ApiKey( + hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450 + ) + integration_session.add(key) + await integration_session.commit() + + # 2. Try to pay for a request that exceeds the limit + with pytest.raises(Exception) as excinfo: + await pay_for_request(key, 100, integration_session) + assert "limit exceeded" in str(excinfo.value).lower() + + # 3. Try to pay for a request that fits + await pay_for_request(key, 50, integration_session) + await integration_session.refresh(key) + # Note: total_spent is updated in adjust_payment_for_tokens, + # but pay_for_request checks it. + # In our current logic, pay_for_request checks (total_spent + cost) > balance_limit. + + +@pytest.mark.asyncio +async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None: + # 1. Create a key with a daily reset policy and old reset date + yesterday = int((datetime.now() - timedelta(days=1)).timestamp()) + key = ApiKey( + hashed_key="daily_reset_key", + balance=10000, + balance_limit=1000, + balance_limit_reset="daily", + balance_limit_reset_date=yesterday, + total_spent=900, + ) + integration_session.add(key) + await integration_session.commit() + + # 2. Pay for a request - should trigger reset first because it's a new day + # Request is 200, total_spent is 900. 900+200 > 1000, + # but reset should happen making total_spent 0, then 0+200 < 1000. + await pay_for_request(key, 200, integration_session) + + await integration_session.refresh(key) + assert key.total_spent == 0 # Reset in pay_for_request happens before charging + # Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests, + # but total_spent is updated in adjust_payment_for_tokens. + # However, the reset logic sets total_spent to 0. + assert key.balance_limit_reset_date > yesterday + + +@pytest.mark.asyncio +async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None: + # 1. Create multiple keys needing reset + yesterday = int((datetime.now() - timedelta(days=1)).timestamp()) + key1 = ApiKey( + hashed_key="job_reset_key_1", + balance=1000, + balance_limit=1000, + balance_limit_reset="daily", + balance_limit_reset_date=yesterday, + total_spent=500, + ) + key2 = ApiKey( + hashed_key="job_reset_key_2", + balance=1000, + balance_limit=1000, + balance_limit_reset="daily", + balance_limit_reset_date=yesterday, + total_spent=800, + ) + integration_session.add(key1) + integration_session.add(key2) + await integration_session.commit() + + # 2. Run the periodic reset logic manually (mocking the background task loop) + # We can't easily run the actual loop because it has a sleep, + # but we can test the logic inside. + + # Implementation of periodic_key_reset logic for testing: + stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) + keys = (await integration_session.exec(stmt)).all() + now = int(time.time()) + for k in keys: + if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]: + k.total_spent = 0 + k.balance_limit_reset_date = now + integration_session.add(k) + await integration_session.commit() + + # 3. Verify resets + await integration_session.refresh(key1) + await integration_session.refresh(key2) + assert key1.total_spent == 0 + assert key2.total_spent == 0 + + +@pytest.mark.asyncio +async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None: + # This requires mocking the router call or testing the logic in balance.py + from routstr.balance import ApiKey + + key = ApiKey(hashed_key="refund_test_key", balance=1000, reserved_balance=100) + integration_session.add(key) + await integration_session.commit() + + # Logic from refund_wallet_endpoint: + key.balance = 0 + key.reserved_balance = 0 + integration_session.add(key) + await integration_session.commit() + + # Verify key still exists + fetched_key = await integration_session.get(ApiKey, "refund_test_key") + assert fetched_key is not None + assert fetched_key.balance == 0 + assert fetched_key.reserved_balance == 0 diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 56fa5b4b..67e218e5 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -14,6 +14,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; import { Key, Copy, Check, Loader2, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; +import { Badge } from '@/components/ui/badge'; +import { KeyOptions } from './key-options'; interface ChildKeyCreatorProps { baseUrl?: string; @@ -198,18 +200,14 @@ export function ChildKeyCreator({ )} -
-
-
- - {costPerKeyMsats && ( - - Cost: {costPerKeyMsats * count} mSats - - )} -
+
+
+
+
+ +
-
- - setBalanceLimit(e.target.value)} - className='w-full' +
+
+
-
- - setValidityDate(e.target.value)} - className='w-full' - /> -
- -
- - +
+
+ {costPerKeyMsats && ( +

+ Cost: {costPerKeyMsats * count} mSats + | + Unit Cost: {costPerKeyMsats / 1000} sats +

+ )}
+ {loading ? ( + <> + + Creating... + + ) : ( + <> + + Generate {count > 1 ? `${count} Keys` : 'Key'} + + )} + +

diff --git a/ui/components/key-options.tsx b/ui/components/key-options.tsx new file mode 100644 index 00000000..b61260e6 --- /dev/null +++ b/ui/components/key-options.tsx @@ -0,0 +1,68 @@ +import { Zap, Calendar, Shield } from 'lucide-react'; +import { Input } from '@/components/ui/input'; + +interface KeyOptionsProps { + balanceLimit: string; + setBalanceLimit: (val: string) => void; + validityDate: string; + setValidityDate: (val: string) => void; + balanceLimitReset: string; + setBalanceLimitReset: (val: string) => void; +} + +export function KeyOptions({ + balanceLimit, + setBalanceLimit, + validityDate, + setValidityDate, + balanceLimitReset, + setBalanceLimitReset, +}: KeyOptionsProps) { + return ( +

+
+ + setBalanceLimit(e.target.value)} + className='h-9 text-xs' + /> +
+ +
+ + setValidityDate(e.target.value)} + className='h-9 text-xs' + /> +
+ +
+ + +
+
+ ); +} diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index 6fdd6553..47c85430 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { KeyOptions } from '@/components/key-options'; type WalletSnapshot = { apiKey: string; @@ -86,9 +87,11 @@ export function CashuPaymentWorkflow({ const [isTopupLoading, setIsTopupLoading] = useState(false); const [isRefunding, setIsRefunding] = useState(false); const [isSyncingBalance, setIsSyncingBalance] = useState(false); - const [hasInteractedCreate, setHasInteractedCreate] = useState(false); const [hasInteractedManage, setHasInteractedManage] = useState(false); const [hasInteractedTopup, setHasInteractedTopup] = useState(false); + const [balanceLimit, setBalanceLimit] = useState(''); + const [balanceLimitReset, setBalanceLimitReset] = useState(''); + const [validityDate, setValidityDate] = useState(''); const activeApiKey = apiKeyInput.trim(); @@ -121,6 +124,15 @@ export function CashuPaymentWorkflow({ const params = new URLSearchParams({ initial_balance_token: initialToken.trim(), }); + if (balanceLimit) params.append('balance_limit', balanceLimit); + if (balanceLimitReset) + params.append('balance_limit_reset', balanceLimitReset); + if (validityDate) { + const timestamp = Math.floor( + new Date(validityDate + 'T23:59:59').getTime() / 1000 + ); + params.append('validity_date', timestamp.toString()); + } const response = await fetch( `${baseUrl}/v1/balance/create?${params.toString()}`, { @@ -154,7 +166,7 @@ export function CashuPaymentWorkflow({ } finally { setIsCreatingKey(false); } - }, [initialToken, baseUrl, onApiKeyCreated]); + }, [initialToken, baseUrl, onApiKeyCreated, balanceLimit, balanceLimitReset, validityDate]); const handleSyncBalance = useCallback(async (): Promise => { if (!activeApiKey) { @@ -256,11 +268,10 @@ export function CashuPaymentWorkflow({ [apiKey, onApiKeyChanged, onWalletInfoUpdated] ); - const showCreateDetails = - hasInteractedCreate || initialToken.trim().length > 0; const showManageDetails = hasInteractedManage || Boolean(walletInfo); const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0; const canTopup = Boolean(activeApiKey); + const showCreateDetails = initialToken.trim().length > 0; return ( @@ -285,12 +296,20 @@ export function CashuPaymentWorkflow({ value={initialToken} onChange={(event) => setInitialToken(event.target.value)} placeholder='cashuA1...' - rows={showCreateDetails ? 4 : 2} + rows={4} className='font-mono text-sm transition-all duration-200' - onFocus={() => setHasInteractedCreate(true)} /> - {showCreateDetails && ( -
+
+ + +
- + Redeems instantly and returns sk- key. +
+ Optional limits can be set above for enhanced security.
- )} +
diff --git a/ui/components/landing/lightning-payment-workflow.tsx b/ui/components/landing/lightning-payment-workflow.tsx index 60bdb6a9..9cf1c3e9 100644 --- a/ui/components/landing/lightning-payment-workflow.tsx +++ b/ui/components/landing/lightning-payment-workflow.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Separator } from '@/components/ui/separator'; +import { KeyOptions } from '@/components/key-options'; type WalletSnapshot = { apiKey: string; @@ -89,7 +90,10 @@ export function LightningPaymentWorkflow({ const [isTopupping, setIsTopupping] = useState(false); const [isRecovering, setIsRecovering] = useState(false); - const [hasInteractedCreate, setHasInteractedCreate] = useState(false); + const [balanceLimit, setBalanceLimit] = useState(''); + const [balanceLimitReset, setBalanceLimitReset] = useState(''); + const [validityDate, setValidityDate] = useState(''); + const [hasInteractedTopup, setHasInteractedTopup] = useState(false); const [hasInteractedRecover, setHasInteractedRecover] = useState(false); @@ -166,13 +170,29 @@ export function LightningPaymentWorkflow({ setIsCreating(true); try { + const payload: { + amount_sats: number; + purpose: string; + balance_limit?: number; + balance_limit_reset?: string; + validity_date?: number; + } = { + amount_sats: amount, + purpose: 'create', + }; + + if (balanceLimit) payload.balance_limit = parseInt(balanceLimit); + if (balanceLimitReset) payload.balance_limit_reset = balanceLimitReset; + if (validityDate) { + payload.validity_date = Math.floor( + new Date(validityDate + 'T23:59:59').getTime() / 1000 + ); + } + const response = await fetch(`${baseUrl}/v1/balance/lightning/invoice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - amount_sats: amount, - purpose: 'create', - }), + body: JSON.stringify(payload), }); if (!response.ok) { @@ -214,7 +234,7 @@ export function LightningPaymentWorkflow({ } finally { setIsCreating(false); } - }, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated]); + }, [createAmount, baseUrl, pollInvoiceStatus, onApiKeyCreated, balanceLimit, balanceLimitReset, validityDate]); const handleTopupInvoice = useCallback(async (): Promise => { const amount = parseInt(topupAmount); @@ -333,14 +353,13 @@ export function LightningPaymentWorkflow({ } }, [recoverInvoice, baseUrl, onApiKeyCreated]); - const showCreateDetails = - hasInteractedCreate || createAmount.trim().length > 0; const showTopupDetails = hasInteractedTopup || topupAmount.trim().length > 0 || topupApiKey.trim().length > 0; const showRecoverDetails = hasInteractedRecover || recoverInvoice.trim().length > 0; + const showCreateDetails = createAmount.trim().length > 0; return ( @@ -367,9 +386,17 @@ export function LightningPaymentWorkflow({ onChange={(event) => setCreateAmount(event.target.value)} placeholder='Amount in sats (e.g., 1000)' className='text-sm' - onFocus={() => setHasInteractedCreate(true)} /> - {showCreateDetails && ( +
+ +
- )} +
From e4b8293d41f2fd7975fe7be236e63a6ab2244e3d Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 5 Feb 2026 01:03:25 +0100 Subject: [PATCH 03/10] lint --- tests/integration/test_key_logic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_key_logic.py b/tests/integration/test_key_logic.py index 12cf4e32..c9496e58 100644 --- a/tests/integration/test_key_logic.py +++ b/tests/integration/test_key_logic.py @@ -70,6 +70,7 @@ async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None # Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests, # but total_spent is updated in adjust_payment_for_tokens. # However, the reset logic sets total_spent to 0. + assert key.balance_limit_reset_date is not None assert key.balance_limit_reset_date > yesterday @@ -102,7 +103,7 @@ async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None # but we can test the logic inside. # Implementation of periodic_key_reset logic for testing: - stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) + stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) keys = (await integration_session.exec(stmt)).all() now = int(time.time()) for k in keys: From b9890e6ad5ed276ba9b114405c0bb126a7c9e238 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 5 Feb 2026 01:19:11 +0100 Subject: [PATCH 04/10] fmt --- tests/integration/test_key_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_key_logic.py b/tests/integration/test_key_logic.py index c9496e58..a8ba21e4 100644 --- a/tests/integration/test_key_logic.py +++ b/tests/integration/test_key_logic.py @@ -103,7 +103,7 @@ async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None # but we can test the logic inside. # Implementation of periodic_key_reset logic for testing: - stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) + stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711 keys = (await integration_session.exec(stmt)).all() now = int(time.time()) for k in keys: From 6a5ed9d063d41c5de406ab5f55fca0a6c4237c93 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 5 Feb 2026 01:20:10 +0100 Subject: [PATCH 05/10] fmt --- ui/components/child-key-creator.tsx | 18 ++++++++++++------ ui/components/key-options.tsx | 2 +- .../landing/cashu-payment-workflow.tsx | 9 ++++++++- .../landing/lightning-payment-workflow.tsx | 10 +++++++++- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 67e218e5..eb000b6e 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -202,7 +202,7 @@ export function ChildKeyCreator({
-
+
-
+
{costPerKeyMsats && (

- Cost: {costPerKeyMsats * count} mSats - | - Unit Cost: {costPerKeyMsats / 1000} sats + Cost:{' '} + + {costPerKeyMsats * count} mSats + + | + Unit Cost:{' '} + + {costPerKeyMsats / 1000} sats +

)}
@@ -251,7 +257,7 @@ export function ChildKeyCreator({
+ {showBalanceLimit && ( +
+ + setBalanceLimit(e.target.value)} + className='h-9 text-xs' + /> +
+ )}