From b0b2ceb1a02f6b06606e47f0c0b52b848c1ec44b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 25 Jan 2026 12:24:02 +0100 Subject: [PATCH] create child keys within the dashboard --- routstr/balance.py | 61 ++++-- routstr/core/main.py | 1 + ui/components/child-key-creator.tsx | 291 ++++++++++++++++++++++++++ ui/components/landing/cheat-sheet.tsx | 14 +- ui/lib/api/services/wallet.ts | 66 +++++- 5 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 ui/components/child-key-creator.tsx diff --git a/routstr/balance.py b/routstr/balance.py index 3ee8ca11..a7c2e467 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -3,7 +3,7 @@ import hashlib from time import monotonic from typing import Annotated, NoReturn -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Body, Depends, Header, HTTPException from pydantic import BaseModel from .auth import validate_bearer_key @@ -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,49 @@ 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, + "api_key": new_keys[0], # For backward compatibility + "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/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx new file mode 100644 index 00000000..e9c836d3 --- /dev/null +++ b/ui/components/child-key-creator.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useState } from 'react'; +import { + WalletService, + CreateChildKeyResponse, +} 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 if (result.api_key) { + setNewKeys([result.api_key]); + } 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 && ( +
+ +
+