diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index abff6d64..106e0c5f 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -434,6 +434,42 @@ Authorization: Bearer sk-... } ``` +### Create Child Key + +Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable). + +```http +POST /v1/balance/child-key +Authorization: Bearer sk-... +``` + +**Request Body:** + +```json +{ + "count": 1 +} +``` + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `count` | integer | Yes | - | Number of child keys to create (1-50) | + +**Response:** + +```json +{ + "api_keys": ["sk-abc...", "sk-def..."], + "count": 2, + "cost_msats": 2000, + "cost_sats": 2, + "parent_balance": 98000, + "parent_balance_sats": 98 +} +``` + ## Provider Discovery ## Admin Settings diff --git a/routstr/balance.py b/routstr/balance.py index 3ee8ca11..1a1e5850 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -251,12 +251,24 @@ async def donate(token: str, ref: str | None = None) -> str: return "Invalid token." +class ChildKeyRequest(BaseModel): + count: int + + @router.post("/child-key") async def create_child_key( + payload: ChildKeyRequest, key: ApiKey = Depends(get_key_from_header), session: AsyncSession = Depends(get_session), ) -> dict: - """Creates a child API key that uses the parent's balance.""" + """Creates one or more child API keys that use the parent's balance.""" + # Log incoming request for debugging + logger.debug(f"Child key creation request: count={payload.count}") + + count = payload.count + if count < 1 or count > 50: + raise HTTPException(status_code=400, detail="Count must be between 1 and 50.") + # Check if this is already a child key if key.parent_key_hash: raise HTTPException( @@ -264,38 +276,48 @@ async def create_child_key( detail="Cannot create a child key for another child key.", ) - cost = settings.child_key_cost + cost_per_key = settings.child_key_cost + total_cost = cost_per_key * count - if key.total_balance < cost: + if key.total_balance < total_cost: raise HTTPException( status_code=402, - detail=f"Insufficient balance to create child key. {cost} mSats required.", + detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.", ) # Deduct cost from parent - key.balance -= cost - key.total_spent += cost + key.balance -= total_cost + key.total_spent += total_cost session.add(key) - # Generate new key + # Generate new keys import secrets - new_key_raw = secrets.token_hex(32) - new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys + new_keys = [] + for _ in range(count): + new_key_raw = secrets.token_hex(32) + new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys + + child_key = ApiKey( + hashed_key=new_key_hash, + balance=0, + parent_key_hash=key.hashed_key, + ) + session.add(child_key) + new_keys.append("sk-" + new_key_hash) - child_key = ApiKey( - hashed_key=new_key_hash, - balance=0, - parent_key_hash=key.hashed_key, - ) - session.add(child_key) await session.commit() - return { - "api_key": "sk-" + new_key_hash, - "cost_msats": cost, + response_data = { + "api_keys": new_keys, + "count": count, + "cost_msats": total_cost, + "cost_sats": total_cost // 1000, "parent_balance": key.balance, + "parent_balance_sats": key.balance // 1000, } + logger.debug(f"Child key creation response: {response_data}") + return response_data @router.api_route( diff --git a/routstr/core/main.py b/routstr/core/main.py index be2cde5f..c18eb76a 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -188,6 +188,7 @@ async def info() -> dict: "mints": global_settings.cashu_mints, "http_url": global_settings.http_url, "onion_url": global_settings.onion_url, + "child_key_cost_msats": global_settings.child_key_cost, } diff --git a/tests/integration/test_child_keys.py b/tests/integration/test_child_keys.py index e868d1cb..c247ef3d 100644 --- a/tests/integration/test_child_keys.py +++ b/tests/integration/test_child_keys.py @@ -6,7 +6,7 @@ from fastapi import HTTPException from sqlmodel.ext.asyncio.session import AsyncSession from routstr.auth import adjust_payment_for_tokens, pay_for_request -from routstr.balance import create_child_key +from routstr.balance import ChildKeyRequest, create_child_key from routstr.core.db import ApiKey from routstr.core.settings import settings @@ -27,13 +27,15 @@ async def test_child_key_flow(integration_session: AsyncSession) -> None: settings.child_key_cost = 1000 # 1 sat # 2. Call create_child_key - result = await create_child_key(parent_key, integration_session) + result = await create_child_key( + ChildKeyRequest(count=1), parent_key, integration_session + ) - assert "api_key" in result + assert "api_keys" in result assert result["cost_msats"] == 1000 assert result["parent_balance"] == 9000 - child_key_raw = result["api_key"][3:] # remove sk- + child_key_raw = result["api_keys"][0][3:] # remove sk- # 3. Verify child key exists in DB child_key_db = await integration_session.get(ApiKey, child_key_raw) @@ -111,7 +113,9 @@ async def test_child_key_insufficient_balance( settings.child_key_cost = 1000 with pytest.raises(HTTPException) as exc: - await create_child_key(parent_key, integration_session) + await create_child_key( + ChildKeyRequest(count=1), parent_key, integration_session + ) assert exc.value.status_code == 402 @@ -132,6 +136,6 @@ async def test_child_key_cannot_create_child(integration_session: AsyncSession) await integration_session.refresh(child_key) with pytest.raises(HTTPException) as exc: - await create_child_key(child_key, integration_session) + await create_child_key(ChildKeyRequest(count=1), child_key, integration_session) assert exc.value.status_code == 400 assert "Cannot create a child key for another child key" in str(exc.value.detail) diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx new file mode 100644 index 00000000..65861731 --- /dev/null +++ b/ui/components/child-key-creator.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useState } from 'react'; +import { WalletService } from '@/lib/api/services/wallet'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} 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 { toast } from 'sonner'; + +interface ChildKeyCreatorProps { + baseUrl?: string; + apiKey?: string; + onApiKeyChange?: (apiKey: string) => void; + costPerKeyMsats?: number; +} + +export function ChildKeyCreator({ + baseUrl, + apiKey: propApiKey, + onApiKeyChange, + costPerKeyMsats, +}: ChildKeyCreatorProps) { + const [internalApiKey, setInternalApiKey] = useState(''); + const [loading, setLoading] = useState(false); + const [count, setCount] = useState(1); + const [newKeys, setNewKeys] = useState([]); + const [resultInfo, setResultInfo] = useState<{ + cost_msats: number; + parent_balance: number; + } | null>(null); + const [copiedKey, setCopiedKey] = useState(null); + + const activeApiKey = propApiKey ?? internalApiKey; + + const handleApiKeyChange = (val: string) => { + setInternalApiKey(val); + onApiKeyChange?.(val); + }; + + const handleCreateKey = async () => { + if (!activeApiKey && baseUrl) { + toast.error('Please provide a Parent API key first'); + return; + } + + const requestedCount = Math.max(1, Math.min(50, Number(count))); + + setLoading(true); + try { + const result = await WalletService.createChildKey( + baseUrl, + activeApiKey, + requestedCount + ); + + console.log('Created child keys:', result); + + if (result.api_keys && result.api_keys.length > 0) { + setNewKeys(result.api_keys); + } else { + throw new Error('No API keys returned from server'); + } + + setResultInfo({ + cost_msats: result.cost_msats, + parent_balance: result.parent_balance, + }); + + toast.success( + `${requestedCount} child API key${ + requestedCount > 1 ? 's' : '' + } created successfully` + ); + } catch (error) { + console.error('Failed to create child key:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to create child key' + ); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = (key: string) => { + navigator.clipboard.writeText(key); + setCopiedKey(key); + toast.success('API key copied to clipboard'); + setTimeout(() => setCopiedKey(null), 2000); + }; + + const copyAllToClipboard = () => { + navigator.clipboard.writeText(newKeys.join('\n')); + toast.success('All API keys copied to clipboard'); + }; + + return ( +
+ + +
+
+ Create Child API Key + + Generate secondary API keys that share your account balance. + +
+ {costPerKeyMsats !== undefined && ( +
+

+ Unit Cost +

+

+ {costPerKeyMsats / 1000} sats +

+
+ )} +
+
+ +
+ {baseUrl && ( +
+ + handleApiKeyChange(e.target.value)} + placeholder='sk-...' + className='font-mono text-sm' + /> +
+ )} + +
+
+
+ + {costPerKeyMsats && ( + + Cost: {costPerKeyMsats * count} mSats + + )} +
+ { + 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' + /> +
+ +
+ +

+ Each key creation has a small one-time fee. +

+ + {newKeys.length > 0 && ( +
+ + + {newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '} + Generated + + + Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now. + You won't be able to see them again. + {resultInfo && ( +
+ Total Cost: {resultInfo.cost_msats / 1000} sats | New + Balance: {resultInfo.parent_balance / 1000} sats +
+ )} +
+
+ +
+
+ + Generated Keys ({newKeys.length}) + + {newKeys.length > 1 && ( + + )} +
+
+ {newKeys.map((key, index) => ( +
+ + {key} + + +
+ ))} +
+
+ + {newKeys.length > 3 && ( +
+ +
+