diff --git a/routstr/balance.py b/routstr/balance.py index 9adbd9b7..e63f2cc3 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -5,6 +5,7 @@ from time import monotonic from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlmodel import select @@ -204,19 +205,57 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None: _refund_cache[key] = (expiry, value) -@router.post("/refund") +@router.post("/refund", response_model=None) async def refund_wallet_endpoint( - authorization: Annotated[str, Header(...)], + authorization: Annotated[str | None, Header()] = None, + x_cashu: Annotated[str | None, Header()] = None, session: AsyncSession = Depends(get_session), -) -> dict[str, str]: - if not authorization.startswith("Bearer "): +) -> JSONResponse | dict[str, str]: + if x_cashu: + # Find the "in" transaction by the original payment token + in_tx_result = await session.exec( + select(CashuTransaction).where( + CashuTransaction.token == x_cashu, + CashuTransaction.type == "in", + ) + ) + in_tx = in_tx_result.first() + if in_tx is None: + raise HTTPException(status_code=404, detail="Refund not found") + + # Use the request_id to find the associated "out" (refund) transaction + if in_tx.request_id is None: + raise HTTPException(status_code=404, detail="Refund not found") + + out_tx_result = await session.exec( + select(CashuTransaction).where( + CashuTransaction.request_id == in_tx.request_id, + CashuTransaction.type == "out", + ) + ) + out_tx = out_tx_result.first() + if out_tx is None: + raise HTTPException(status_code=404, detail="Refund not found") + if out_tx.swept: + raise HTTPException(status_code=410, detail="Refund has been swept") + + out_tx.collected = True + session.add(out_tx) + await session.commit() + body: dict[str, str] = {"token": out_tx.token} + if out_tx.unit == "sat": + body["sats"] = str(out_tx.amount) + else: + body["msats"] = str(out_tx.amount) + return JSONResponse(content=body, headers={"X-Cashu": out_tx.token}) + + if authorization is None or not authorization.startswith("Bearer "): raise HTTPException( status_code=401, detail="Invalid authorization. Use 'Bearer ' or 'Bearer '", ) bearer_value: str = authorization[7:] - key: ApiKey = await validate_bearer_key(bearer_value, session) if key.total_balance <= 0: diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py new file mode 100644 index 00000000..b9ff980d --- /dev/null +++ b/tests/unit/test_balance.py @@ -0,0 +1,116 @@ +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.responses import JSONResponse + +from routstr.balance import refund_wallet_endpoint +from routstr.core.db import CashuTransaction + + +def _make_cashu_tx( + token: str, + amount: int, + unit: str, + type: str = "out", + request_id: str | None = "req-abc", + swept: bool = False, + collected: bool = False, +) -> CashuTransaction: + tx = CashuTransaction(token=token, amount=amount, unit=unit, type=type, request_id=request_id) + tx.swept = swept + tx.collected = collected + return tx + + +def _exec_result(tx: CashuTransaction | None) -> MagicMock: + result = MagicMock() + result.first.return_value = tx + return result + + +@pytest.mark.asyncio +async def test_refund_x_cashu_returns_token() -> None: + x_cashu_token = "cashuAtest_token_value" + in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-abc") + out_tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat", type="out", request_id="req-abc") + + session = MagicMock() + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) + session.add = MagicMock() + session.commit = AsyncMock() + + result = await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu=x_cashu_token, + session=session, + ) + + assert isinstance(result, JSONResponse) + body = json.loads(result.body) + assert body["token"] == "cashuArefund_token" + assert body["msats"] == "1000" + assert result.headers["X-Cashu"] == "cashuArefund_token" + assert out_tx.collected is True + + +@pytest.mark.asyncio +async def test_refund_x_cashu_sat_unit() -> None: + x_cashu_token = "cashuAsat_token" + in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="sat", type="in", request_id="req-sat") + out_tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat", type="out", request_id="req-sat") + + session = MagicMock() + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) + session.add = MagicMock() + session.commit = AsyncMock() + + result = await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu=x_cashu_token, + session=session, + ) + + assert isinstance(result, JSONResponse) + body = json.loads(result.body) + assert body["token"] == "cashuArefund_sat" + assert body["sats"] == "500" + assert "msats" not in body + assert result.headers["X-Cashu"] == "cashuArefund_sat" + + +@pytest.mark.asyncio +async def test_refund_x_cashu_not_found_raises_404() -> None: + from fastapi import HTTPException + + session = MagicMock() + session.exec = AsyncMock(return_value=_exec_result(None)) + + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu="cashuAmissing_token", + session=session, + ) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_refund_x_cashu_swept_raises_410() -> None: + from fastapi import HTTPException + + in_tx = _make_cashu_tx(token="cashuAswept_token", amount=0, unit="msat", type="in", request_id="req-swept") + out_tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", type="out", request_id="req-swept", swept=True) + + session = MagicMock() + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) + + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu="cashuAswept_token", + session=session, + ) + + assert exc_info.value.status_code == 410 diff --git a/ui/components/api-key-input.tsx b/ui/components/api-key-input.tsx new file mode 100644 index 00000000..85e62f05 --- /dev/null +++ b/ui/components/api-key-input.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import * as React from 'react'; +import { Input } from '@/components/ui/input'; + +interface ApiKeyInputProps extends React.ComponentProps<'input'> { + onApiKeyChange: (apiKey: string) => void; +} + +export function ApiKeyInput({ + value, + onApiKeyChange, + ...props +}: ApiKeyInputProps) { + const [internalValue, setInternalValue] = useState(value || ''); + + useEffect(() => { + setInternalValue(value || ''); + }, [value]); + + useEffect(() => { + const handler = setTimeout(() => { + onApiKeyChange(internalValue as string); + }, 300); + + return () => clearTimeout(handler); + }, [internalValue, onApiKeyChange]); + + return ( + setInternalValue(e.target.value)} + placeholder='sk-...' + className='font-mono text-sm' + {...props} + /> + ); +} diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 1c67b442..2f462179 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState } from 'react'; +import { useWalletInfo } from '@/hooks/use-wallet-info'; import { WalletService } from '@/lib/api/services/wallet'; +import { ApiKeyInput } from './api-key-input'; import { Button } from '@/components/ui/button'; import { Card, @@ -14,17 +16,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; -import { - Key, - Copy, - Check, - Loader2, - RotateCcw, - Plus, - Trash2, -} from 'lucide-react'; +import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; -import { Badge } from '@/components/ui/badge'; import { KeyOptions } from './key-options'; interface KeyConfig { @@ -42,6 +35,14 @@ interface ChildKeyCreatorProps { costPerKeyMsats?: number; } +function formatSats(msats: number): string { + return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +} + +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + export function ChildKeyCreator({ baseUrl, apiKey: propApiKey, @@ -50,6 +51,7 @@ export function ChildKeyCreator({ }: ChildKeyCreatorProps) { const [internalApiKey, setInternalApiKey] = useState(''); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [configs, setConfigs] = useState([ { id: crypto.randomUUID(), @@ -59,15 +61,15 @@ export function ChildKeyCreator({ validityDate: '', }, ]); - 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 activeApiKey = propApiKey ?? internalApiKey; + const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey); + + const handleApiKeyChange = (val: string) => { + setInternalApiKey(val); + onApiKeyChange?.(val); + }; + const [newKeys, setNewKeys] = useState([]); const [resultInfo, setResultInfo] = useState<{ cost_msats: number; @@ -75,13 +77,6 @@ export function ChildKeyCreator({ } | null>(null); const [copiedKey, setCopiedKey] = useState(null); - const activeApiKey = propApiKey ?? internalApiKey; - - const handleApiKeyChange = (val: string) => { - setInternalApiKey(val); - onApiKeyChange?.(val); - }; - const addConfig = () => { setConfigs([ ...configs, @@ -112,6 +107,7 @@ export function ChildKeyCreator({ } setLoading(true); + setError(null); try { let allNewKeys: string[] = []; let totalCost = 0; @@ -152,55 +148,21 @@ export function ChildKeyCreator({ ); } catch (error) { console.error('Failed to create child key:', error); - toast.error( - error instanceof Error ? error.message : 'Failed to create child key' - ); + let errorMessage = + error instanceof Error ? error.message : 'Failed to create child key'; + try { + const parsed = JSON.parse(errorMessage); + errorMessage = + parsed.detail?.error?.message || + (typeof parsed.detail === 'string' ? parsed.detail : errorMessage); + } catch {} + setError(errorMessage); + toast.error(errorMessage); } finally { setLoading(false); } }; - 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); @@ -243,12 +205,55 @@ export function ChildKeyCreator({ - handleApiKeyChange(e.target.value)} - placeholder='sk-...' - className='font-mono text-sm' - /> +
+
+ +
+ +
+ {walletInfo && ( +
+
+ + Spendable Balance + + + {formatSats(walletInfo.balanceMsats)} sats + +
+
+ + Total Requests + + + {walletInfo.totalRequests} + +
+
+ + Total Spent + +
+

+ {formatSats(walletInfo.totalSpent)} sats +

+

+ {formatMsats(walletInfo.totalSpent)} msats +

+
+
+
+ )} )} @@ -362,11 +367,18 @@ export function ChildKeyCreator({ )} - -

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

+ {error && ( + + Error + {error} + + )} + +

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

+ {newKeys.length > 0 && (
@@ -458,89 +470,6 @@ export function ChildKeyCreator({
- - - - Check Child Key Status - - View the current spending, limit, and expiration status of any child - key. - - - -
-
- - setChildKeyToCheck(e.target.value)} - placeholder='sk-...' - className='font-mono text-sm' - /> -
- - - {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/components/landing/api-key-manager.tsx b/ui/components/landing/api-key-manager.tsx index e8c2238e..7cc4aafb 100644 --- a/ui/components/landing/api-key-manager.tsx +++ b/ui/components/landing/api-key-manager.tsx @@ -239,7 +239,7 @@ export function ApiKeyManager({ className='gap-2' > - {isRefunding ? 'Processing...' : 'Refund & Delete Key'} + {isRefunding ? 'Processing...' : 'Refund Key'} Burns the key and returns a fresh Cashu token. diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index d2e80c47..e2244ea0 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -1,16 +1,17 @@ 'use client'; import { type JSX, useCallback, useState } from 'react'; -import { Copy, RefreshCcw, Trash2 } from 'lucide-react'; +import { Copy, RefreshCcw } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { toast } from 'sonner'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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'; -import { WalletBalanceStats } from './wallet-balance-stats'; -import type { ChildKeyInfo, WalletSnapshot } from './key-info-details'; +import { ApiKeyInput } from '../api-key-input'; +import type { WalletSnapshot } from './key-info-details'; +import { useWalletInfo } from '@/hooks/use-wallet-info'; export type RefundReceipt = { token?: string; @@ -29,80 +30,41 @@ interface CashuPaymentWorkflowProps { onRefundComplete?: (receipt: RefundReceipt) => void; } -async function fetchWalletInfo( - baseUrl: string, - apiKey: string -): Promise { - const response = await fetch(`${baseUrl}/v1/balance/info`, { - cache: 'no-store', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || 'Unable to load wallet info'); - } - - const payload = (await response.json()) as { - api_key: string; - balance: number; - reserved?: number; - is_child: boolean; - parent_key: string | null; - total_requests: number; - total_spent: number; - balance_limit: number | null; - balance_limit_reset: string | null; - validity_date: number | null; - child_keys?: ChildKeyInfo[]; - }; - - return { - apiKey: payload.api_key || apiKey, - balanceMsats: payload.balance ?? 0, - reservedMsats: payload.reserved ?? 0, - isChild: payload.is_child, - parentKey: payload.parent_key, - totalRequests: payload.total_requests, - totalSpent: payload.total_spent, - balanceLimit: payload.balance_limit, - balanceLimitReset: payload.balance_limit_reset, - validityDate: payload.validity_date, - childKeys: payload.child_keys, - }; -} - function formatSats(msats: number): string { return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); } +function formatMsats(msats: number): string { + return new Intl.NumberFormat('en-US').format(msats); +} + export function CashuPaymentWorkflow({ baseUrl, apiKey = '', - walletInfo = null, + walletInfo: propWalletInfo = null, onApiKeyCreated, - onApiKeyChanged, onWalletInfoUpdated, - onRefundComplete, }: CashuPaymentWorkflowProps): JSX.Element { const [initialToken, setInitialToken] = useState(''); const [topupToken, setTopupToken] = useState(''); const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [isCreatingKey, setIsCreatingKey] = useState(false); const [isTopupLoading, setIsTopupLoading] = useState(false); - const [isRefunding, setIsRefunding] = useState(false); - const [isSyncingBalance, setIsSyncingBalance] = 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 [error, setError] = useState(null); const activeApiKey = apiKeyInput.trim(); + const { + data: queryWalletInfo, + refetch, + isFetching, + } = useWalletInfo(baseUrl, activeApiKey); + const walletInfo = propWalletInfo ?? queryWalletInfo ?? null; + const handleCopy = useCallback(async (value: string): Promise => { if (!value) { return; @@ -203,20 +165,18 @@ export function CashuPaymentWorkflow({ return; } - setIsSyncingBalance(true); + setError(null); try { - const snapshot = await fetchWalletInfo(baseUrl, activeApiKey); - onWalletInfoUpdated?.(snapshot); + await refetch(); toast.success('Balance synced'); } catch (error) { console.error(error); - toast.error( - error instanceof Error ? error.message : 'Failed to sync balance' - ); - } finally { - setIsSyncingBalance(false); + const message = + error instanceof Error ? error.message : 'Failed to sync balance'; + setError(message); + toast.error(message); } - }, [activeApiKey, baseUrl, onWalletInfoUpdated]); + }, [activeApiKey, refetch]); const handleTopup = useCallback(async (): Promise => { if (!activeApiKey) { @@ -245,59 +205,25 @@ export function CashuPaymentWorkflow({ const payload = (await response.json()) as { msats: number }; toast.success(`Added ${formatSats(payload.msats)} sats`); setTopupToken(''); - const snapshot = await fetchWalletInfo(baseUrl, activeApiKey); - onApiKeyCreated?.(snapshot.apiKey, snapshot); + await refetch(); } catch (error) { console.error(error); toast.error(error instanceof Error ? error.message : 'Top-up failed'); } finally { setIsTopupLoading(false); } - }, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]); - - const handleRefund = useCallback(async (): Promise => { - if (!activeApiKey) { - toast.error('Paste an API key first'); - return; - } - - setIsRefunding(true); - try { - const response = await fetch(`${baseUrl}/v1/balance/refund`, { - method: 'POST', - headers: { - Authorization: `Bearer ${activeApiKey}`, - }, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || 'Refund failed'); - } - const payload = (await response.json()) as RefundReceipt; - onRefundComplete?.(payload); - onWalletInfoUpdated?.(null); - setApiKeyInput(''); - toast.success('Refund requested'); - } catch (error) { - console.error(error); - toast.error(error instanceof Error ? error.message : 'Refund failed'); - } finally { - setIsRefunding(false); - } - }, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]); + }, [activeApiKey, baseUrl, topupToken, refetch]); const handleApiKeyChange = useCallback( (newKey: string) => { setApiKeyInput(newKey); - onApiKeyChanged?.(newKey); if (newKey !== apiKey) { onWalletInfoUpdated?.(null); } }, - [apiKey, onApiKeyChanged, onWalletInfoUpdated] + [apiKey, onWalletInfoUpdated] ); - const showManageDetails = hasInteractedManage || Boolean(walletInfo); const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0; const canTopup = Boolean(activeApiKey); const showCreateDetails = initialToken.trim().length > 0; @@ -365,40 +291,72 @@ export function CashuPaymentWorkflow({ )}
- handleApiKeyChange(event.target.value)} - placeholder='sk-...' - className='font-mono text-sm' - onFocus={() => setHasInteractedManage(true)} + onApiKeyChange={handleApiKeyChange} />
+
- {showManageDetails && ( - + {walletInfo && ( +
+
+ + Spendable Balance + + + {formatSats(walletInfo.balanceMsats)} sats + +
+
+ + Total Requests + + + {walletInfo.totalRequests} + +
+
+ + Total Spent + +
+

+ {formatSats(walletInfo.totalSpent)} sats +

+

+ {formatMsats(walletInfo.totalSpent)} msats +

+
+
+
+ )} + {error && ( + + Error + {error} + )} @@ -444,28 +402,6 @@ export function CashuPaymentWorkflow({ )} - - - -
-
- 4 · Refund -
-
- - - Burns the key and returns a fresh Cashu token. - -
-
); diff --git a/ui/components/landing/cheat-sheet.tsx b/ui/components/landing/cheat-sheet.tsx index 7004ec98..f5fd803c 100644 --- a/ui/components/landing/cheat-sheet.tsx +++ b/ui/components/landing/cheat-sheet.tsx @@ -18,7 +18,6 @@ import { type RefundReceipt, } from './cashu-payment-workflow'; import { LightningPaymentWorkflow } from './lightning-payment-workflow'; -import { ApiKeyManager } from './api-key-manager'; import { KeyInfoDetails, type WalletSnapshot } from './key-info-details'; import { ChildKeyCreator } from '@/components/child-key-creator'; @@ -135,8 +134,8 @@ export function CheatSheet(): JSX.Element { const handleRefundComplete = useCallback((receipt: RefundReceipt) => { setRefundReceipt(receipt); - setWalletInfo(null); - setApiKeyInput(''); + // setWalletInfo(null); // Keep info + // setApiKeyInput(''); // Keep input }, []); const handleRefreshInfo = useCallback(async (): Promise => { @@ -391,12 +390,11 @@ export function CheatSheet(): JSX.Element { - + Cashu Lightning - Manage Keys - Key Details Child Keys + Key Management @@ -413,8 +411,8 @@ export function CheatSheet(): JSX.Element { /> - - + @@ -454,16 +452,6 @@ export function CheatSheet(): JSX.Element { )} - - - - void; onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void; + onRefundComplete?: (receipt: RefundReceipt) => void; } export function KeyInfoDetails({ baseUrl, apiKey = '', - walletInfo = null, + walletInfo: propWalletInfo = null, onApiKeyChanged, onWalletInfoUpdated, -}: KeyInfoDetailsProps): JSX.Element { + onRefundComplete, +}: KeyInfoDetailsProps): React.ReactNode { const [apiKeyInput, setApiKeyInput] = useState(apiKey); - const [isRefreshing, setIsRefreshing] = useState(false); const [isResetting, setIsResetting] = useState(null); + const [isRefunding, setIsRefunding] = useState(false); + const [error, setError] = useState(null); + + const { + data: queryWalletInfo, + refetch, + isFetching, + } = useWalletInfo(baseUrl, apiKeyInput); + const walletInfo = propWalletInfo ?? queryWalletInfo ?? null; // Sync internal state with props if they change useEffect(() => { setApiKeyInput(apiKey); }, [apiKey]); - const fetchDetails = useCallback( - async (keyToFetch: string) => { - setIsRefreshing(true); - try { - const response = await fetch(`${baseUrl}/v1/balance/info`, { - headers: { Authorization: `Bearer ${keyToFetch}` }, - }); - if (!response.ok) { - throw new Error('Failed to fetch key info'); - } - const payload = await response.json(); - const snapshot: WalletSnapshot = { - apiKey: payload.api_key || keyToFetch, - balanceMsats: payload.balance ?? 0, - reservedMsats: payload.reserved ?? 0, - isChild: payload.is_child, - parentKey: payload.parent_key, - totalRequests: payload.total_requests, - totalSpent: payload.total_spent, - balanceLimit: payload.balance_limit, - balanceLimitReset: payload.balance_limit_reset, - validityDate: payload.validity_date, - childKeys: payload.child_keys, - }; - onWalletInfoUpdated?.(snapshot); - toast.success('Key details synced'); - } catch (error) { - toast.error( - error instanceof Error ? error.message : 'Failed to fetch details' - ); - } finally { - setIsRefreshing(false); - } - }, - [baseUrl, onWalletInfoUpdated] - ); - - const handleRefresh = async () => { + const handleRefresh = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); if (!apiKeyInput) return; - await fetchDetails(apiKeyInput); + await refetch(); }; const handleKeyChange = (newKey: string) => { setApiKeyInput(newKey); + setError(null); onApiKeyChanged?.(newKey); // Optionally clear info when key changes if (newKey !== apiKey) { @@ -125,7 +104,7 @@ export function KeyInfoDetails({ try { await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey); toast.success('Child key spent reset'); - await fetchDetails(apiKeyInput); + await refetch(); } catch (error) { toast.error( error instanceof Error ? error.message : 'Failed to reset child key' @@ -135,6 +114,36 @@ export function KeyInfoDetails({ } }; + const handleRefund = useCallback(async (): Promise => { + if (!apiKeyInput) { + toast.error('Paste an API key first'); + return; + } + + setIsRefunding(true); + try { + const response = await fetch(`${baseUrl}/v1/balance/refund`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKeyInput}`, + }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Refund failed'); + } + const receipt = (await response.json()) as RefundReceipt; + onRefundComplete?.(receipt); + toast.success('Refund completed'); + await refetch(); + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [apiKeyInput, baseUrl, onRefundComplete, refetch]); + const formatSats = (msats: number) => new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); const formatMsats = (msats: number) => @@ -153,17 +162,11 @@ export function KeyInfoDetails({
- handleKeyChange(e.target.value)} - placeholder='sk-...' - className='font-mono text-sm' - /> +
+ {error && ( + + Error + {error} + + )}
@@ -228,6 +238,14 @@ export function KeyInfoDetails({ {formatDate(walletInfo.validityDate)}
+ + + + + + Infos + +
Spendable Balance @@ -236,14 +254,6 @@ export function KeyInfoDetails({ {formatSats(walletInfo.balanceMsats)} sats
-
-
- - - - Consumption - -
Total Requests @@ -390,18 +400,16 @@ export function KeyInfoDetails({ )} -
+
diff --git a/ui/components/landing/key-info-display.tsx b/ui/components/landing/key-info-display.tsx new file mode 100644 index 00000000..f1a4d03a --- /dev/null +++ b/ui/components/landing/key-info-display.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Copy, RotateCcw } from 'lucide-react'; +import type { WalletSnapshot } from './key-info-details'; +import { toast } from 'sonner'; + +interface KeyInfoDisplayProps { + walletInfo: WalletSnapshot; + onResetSpent?: (childKey: string) => Promise; + isResetting?: string | null; +} + +const formatSats = (msats: number) => + new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); +const formatMsats = (msats: number) => + new Intl.NumberFormat('en-US').format(msats); +const formatDate = (timestamp: number | null) => + timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never'; + +export function KeyInfoDisplay({ + walletInfo, + onResetSpent, + isResetting, +}: KeyInfoDisplayProps) { + const handleCopy = (value: string) => { + navigator.clipboard.writeText(value); + toast.success('Copied to clipboard'); + }; + + return ( +
+
+ + + Status & Identity + + +
+ Type + + {walletInfo.isChild ? 'Child Key' : 'Parent Key'} + +
+ {walletInfo.parentKey && ( +
+ + Parent Key + +
+ + {walletInfo.parentKey} + + +
+
+ )} +
+ Validity + + {formatDate(walletInfo.validityDate)} + +
+
+
+ + + + Infos + + +
+ + Spendable Balance + + + {formatSats(walletInfo.balanceMsats)} sats + +
+
+ + Total Requests + + + {walletInfo.totalRequests} + +
+
+ Total Spent +
+

+ {formatSats(walletInfo.totalSpent)} sats +

+

+ {formatMsats(walletInfo.totalSpent)} msats +

+
+
+ {walletInfo.balanceLimit !== null && ( +
+
+ + Spend Limit + + + {formatSats(walletInfo.balanceLimit)} sats + +
+ {walletInfo.balanceLimitReset && ( +
+ + Reset Policy + + + {walletInfo.balanceLimitReset} + +
+ )} +
+ )} +
+
+
+ + {!walletInfo.isChild && + walletInfo.childKeys && + walletInfo.childKeys.length > 0 && ( + + + + Child Keys ({walletInfo.childKeys.length}) + + + Secondary keys using this account's balance + + + +
+ {walletInfo.childKeys.map((ck) => ( +
+
+ + {ck.api_key} + +
+ + {onResetSpent && ( + + )} +
+
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/ui/hooks/use-wallet-info.ts b/ui/hooks/use-wallet-info.ts new file mode 100644 index 00000000..fd7d159e --- /dev/null +++ b/ui/hooks/use-wallet-info.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import type { WalletSnapshot } from '@/components/landing/key-info-details'; + +export function useWalletInfo(baseUrl: string, apiKey: string) { + return useQuery({ + queryKey: ['walletInfo', baseUrl, apiKey], + queryFn: async (): Promise => { + const response = await fetch(`${baseUrl}/v1/balance/info`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Unable to load wallet info'); + } + + const payload = await response.json(); + return { + apiKey: payload.api_key || apiKey, + balanceMsats: payload.balance ?? 0, + reservedMsats: payload.reserved ?? 0, + isChild: payload.is_child, + parentKey: payload.parent_key, + totalRequests: payload.total_requests, + totalSpent: payload.total_spent, + balanceLimit: payload.balance_limit, + balanceLimitReset: payload.balance_limit_reset, + validityDate: payload.validity_date, + childKeys: payload.child_keys, + }; + }, + enabled: !!baseUrl && !!apiKey, + staleTime: 5000, // Consider data stale after 5 seconds + }); +}