mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-30 23:36:15 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ebb7d71e1 | ||
|
|
bbf1e65a5d | ||
|
|
42258ae39c | ||
|
|
04d6903369 | ||
|
|
b0b2ceb1a0 | ||
|
|
9dfa58d69f |
@@ -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
|
||||
|
||||
+40
-18
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [resultInfo, setResultInfo] = useState<{
|
||||
cost_msats: number;
|
||||
parent_balance: number;
|
||||
} | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(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 (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<CardTitle>Create Child API Key</CardTitle>
|
||||
<CardDescription>
|
||||
Generate secondary API keys that share your account balance.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{costPerKeyMsats !== undefined && (
|
||||
<div className='text-right'>
|
||||
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||
Unit Cost
|
||||
</p>
|
||||
<p className='text-primary text-sm font-bold'>
|
||||
{costPerKeyMsats / 1000} sats
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
{baseUrl && (
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
Parent API Key
|
||||
</label>
|
||||
<Input
|
||||
value={activeApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
Number of keys
|
||||
</label>
|
||||
{costPerKeyMsats && (
|
||||
<span className='text-muted-foreground text-[10px]'>
|
||||
Cost: {costPerKeyMsats * count} mSats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
max={50}
|
||||
value={count}
|
||||
onChange={(e) => {
|
||||
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'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateKey}
|
||||
disabled={loading || (!!baseUrl && !activeApiKey)}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className='mr-2 h-4 w-4' />
|
||||
Generate {count > 1 ? `${count} Keys` : 'Key'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Each key creation has a small one-time fee.
|
||||
</p>
|
||||
|
||||
{newKeys.length > 0 && (
|
||||
<div className='mt-6 space-y-4'>
|
||||
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
|
||||
<AlertTitle className='text-green-800 dark:text-green-400'>
|
||||
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
|
||||
Generated
|
||||
</AlertTitle>
|
||||
<AlertDescription className='text-green-700 dark:text-green-500'>
|
||||
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
|
||||
You won't be able to see them again.
|
||||
{resultInfo && (
|
||||
<div className='mt-2 font-medium opacity-80'>
|
||||
Total Cost: {resultInfo.cost_msats / 1000} sats | New
|
||||
Balance: {resultInfo.parent_balance / 1000} sats
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
||||
Generated Keys ({newKeys.length})
|
||||
</span>
|
||||
{newKeys.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 text-[10px] uppercase'
|
||||
onClick={copyAllToClipboard}
|
||||
>
|
||||
<Copy className='mr-1 h-3 w-3' />
|
||||
Copy All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
{newKeys.map((key, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='group relative flex items-center gap-2'
|
||||
>
|
||||
<code className='bg-muted/50 flex-1 rounded border p-2.5 font-mono text-[10px] break-all sm:text-xs'>
|
||||
{key}
|
||||
</code>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-8 w-8 shrink-0'
|
||||
onClick={() => copyToClipboard(key)}
|
||||
>
|
||||
{copiedKey === key ? (
|
||||
<Check className='h-3.5 w-3.5 text-green-500' />
|
||||
) : (
|
||||
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newKeys.length > 3 && (
|
||||
<div className='space-y-2'>
|
||||
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||
Bulk Export (All Keys)
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<textarea
|
||||
readOnly
|
||||
value={newKeys.join('\n')}
|
||||
rows={Math.min(newKeys.length, 6)}
|
||||
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
|
||||
/>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='absolute right-2 bottom-2 h-7 text-[10px]'
|
||||
onClick={copyAllToClipboard}
|
||||
>
|
||||
Copy Bulk
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ConfigurationService } from '@/lib/api/services/configuration';
|
||||
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||
import { ApiKeyManager } from './api-key-manager';
|
||||
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||
|
||||
type NodeInfo = {
|
||||
name: string;
|
||||
@@ -23,6 +24,7 @@ type NodeInfo = {
|
||||
mints: string[];
|
||||
http_url?: string | null;
|
||||
onion_url?: string | null;
|
||||
child_key_cost_msats?: number;
|
||||
};
|
||||
|
||||
type WalletSnapshot = {
|
||||
@@ -362,10 +364,11 @@ export function CheatSheet(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue='cashu' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-3'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
|
||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='cashu' className='space-y-4'>
|
||||
@@ -422,6 +425,15 @@ export function CheatSheet(): JSX.Element {
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='child-keys' className='space-y-4'>
|
||||
<ChildKeyCreator
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
onApiKeyChange={handleApiKeyChanged}
|
||||
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,28 @@ export type RedeemTokenResponse = z.infer<typeof RedeemTokenResponseSchema>;
|
||||
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
|
||||
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
|
||||
|
||||
export interface BalanceDetail {
|
||||
mint_url: string;
|
||||
unit: string;
|
||||
wallet_balance: number;
|
||||
user_balance: number;
|
||||
owner_balance: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WithdrawResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CreateChildKeyResponse {
|
||||
api_keys: string[];
|
||||
count: number;
|
||||
cost_msats: number;
|
||||
cost_sats: number;
|
||||
parent_balance: number;
|
||||
parent_balance_sats: number;
|
||||
}
|
||||
|
||||
export class WalletService {
|
||||
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
|
||||
try {
|
||||
@@ -108,17 +130,38 @@ export class WalletService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BalanceDetail {
|
||||
mint_url: string;
|
||||
unit: string;
|
||||
wallet_balance: number;
|
||||
user_balance: number;
|
||||
owner_balance: number;
|
||||
error?: string;
|
||||
}
|
||||
static async createChildKey(
|
||||
baseUrl?: string,
|
||||
apiKey?: string,
|
||||
count: number = 1
|
||||
): Promise<CreateChildKeyResponse> {
|
||||
try {
|
||||
if (baseUrl && apiKey) {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/child-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
|
||||
export interface WithdrawResponse {
|
||||
token: string;
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Failed to create child key');
|
||||
}
|
||||
|
||||
return (await response.json()) as CreateChildKeyResponse;
|
||||
}
|
||||
|
||||
return await apiClient.post<CreateChildKeyResponse>(
|
||||
'/v1/balance/child-key',
|
||||
{ count }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating child key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user