From 4ee654331fe012fc53a8d8ba8e9af7ccb77a519f Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 23 Mar 2026 22:01:54 +0100 Subject: [PATCH 01/10] fix refunding not displayed & clean up --- ui/components/child-key-creator.tsx | 111 ++++-------------- ui/components/landing/api-key-manager.tsx | 2 +- .../landing/cashu-payment-workflow.tsx | 91 ++++---------- ui/components/landing/cheat-sheet.tsx | 26 ++-- ui/components/landing/key-info-details.tsx | 68 +++++++++-- 5 files changed, 108 insertions(+), 190 deletions(-) diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 1c67b442..460108b9 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -50,6 +50,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(), @@ -112,6 +113,7 @@ export function ChildKeyCreator({ } setLoading(true); + setError(null); try { let allNewKeys: string[] = []; let totalCost = 0; @@ -152,9 +154,14 @@ 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 || parsed.message || errorMessage; + } catch {} + setError(errorMessage); + toast.error(errorMessage); } finally { setLoading(false); } @@ -362,11 +369,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 +472,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..7af3e231 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -2,6 +2,7 @@ import { type JSX, useCallback, useState } from 'react'; import { Copy, RefreshCcw, Trash2 } 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'; @@ -43,7 +44,15 @@ async function fetchWalletInfo( if (!response.ok) { const errorText = await response.text(); - throw new Error(errorText || 'Unable to load wallet info'); + let errorMessage = errorText || 'Unable to load wallet info'; + try { + const parsed = JSON.parse(errorText); + // The error structure seems to be { detail: { error: { message: ... } } } + errorMessage = + parsed.detail?.error?.message || + (typeof parsed.detail === 'string' ? parsed.detail : errorMessage); + } catch {} + throw new Error(errorMessage); } const payload = (await response.json()) as { @@ -84,22 +93,20 @@ export function CashuPaymentWorkflow({ apiKey = '', walletInfo = 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(); @@ -204,15 +211,17 @@ export function CashuPaymentWorkflow({ } setIsSyncingBalance(true); + setError(null); try { const snapshot = await fetchWalletInfo(baseUrl, activeApiKey); onWalletInfoUpdated?.(snapshot); toast.success('Balance synced'); } catch (error) { console.error(error); - toast.error( - error instanceof Error ? error.message : 'Failed to sync balance' - ); + const message = + error instanceof Error ? error.message : 'Failed to sync balance'; + setError(message); + toast.error(message); } finally { setIsSyncingBalance(false); } @@ -255,46 +264,14 @@ export function CashuPaymentWorkflow({ } }, [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]); - const handleApiKeyChange = useCallback( (newKey: string) => { setApiKeyInput(newKey); - onApiKeyChanged?.(newKey); if (newKey !== apiKey) { onWalletInfoUpdated?.(null); } }, - [apiKey, onApiKeyChanged, onWalletInfoUpdated] + [apiKey] ); const showManageDetails = hasInteractedManage || Boolean(walletInfo); @@ -376,12 +353,12 @@ export function CashuPaymentWorkflow({ + - - 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({ @@ -52,10 +54,12 @@ export function KeyInfoDetails({ walletInfo = null, onApiKeyChanged, onWalletInfoUpdated, + onRefundComplete, }: KeyInfoDetailsProps): JSX.Element { const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [isRefreshing, setIsRefreshing] = useState(false); const [isResetting, setIsResetting] = useState(null); + const [isRefunding, setIsRefunding] = useState(false); // Sync internal state with props if they change useEffect(() => { @@ -135,6 +139,37 @@ 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); + // Removed onWalletInfoUpdated?.(null); // Prevents card disappearance + toast.success('Refund completed'); + await fetchDetails(apiKeyInput); // Refresh to show burned status + } catch (error) { + console.error(error); + toast.error(error instanceof Error ? error.message : 'Refund failed'); + } finally { + setIsRefunding(false); + } + }, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]); + const formatSats = (msats: number) => new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); const formatMsats = (msats: number) => @@ -163,7 +198,6 @@ export function KeyInfoDetails({ @@ -228,6 +262,14 @@ export function KeyInfoDetails({ {formatDate(walletInfo.validityDate)} + + + + + + Infos + +
Spendable Balance @@ -236,14 +278,6 @@ export function KeyInfoDetails({ {formatSats(walletInfo.balanceMsats)} sats
-
-
- - - - Consumption - -
Total Requests @@ -390,7 +424,7 @@ export function KeyInfoDetails({ )} -
+
+
)} From 750193e99d136aa9445b96ae3693d939689757d4 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 23 Mar 2026 22:20:36 +0100 Subject: [PATCH 02/10] clean up ui --- ui/components/api-key-input.tsx | 39 ++++ ui/components/child-key-creator.tsx | 23 +- .../landing/cashu-payment-workflow.tsx | 9 +- ui/components/landing/key-info-details.tsx | 21 +- ui/components/landing/key-info-display.tsx | 198 ++++++++++++++++++ 5 files changed, 260 insertions(+), 30 deletions(-) create mode 100644 ui/components/api-key-input.tsx create mode 100644 ui/components/landing/key-info-display.tsx 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 460108b9..d82added 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { WalletService } from '@/lib/api/services/wallet'; +import { ApiKeyInput } from './api-key-input'; import { Button } from '@/components/ui/button'; import { Card, @@ -250,12 +251,22 @@ export function ChildKeyCreator({ - handleApiKeyChange(e.target.value)} - placeholder='sk-...' - className='font-mono text-sm' - /> +
+
+ +
+ +
)} diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index 7af3e231..5608833f 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -11,6 +11,7 @@ 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 { ApiKeyInput } from '../api-key-input'; import type { ChildKeyInfo, WalletSnapshot } from './key-info-details'; export type RefundReceipt = { @@ -271,7 +272,7 @@ export function CashuPaymentWorkflow({ onWalletInfoUpdated?.(null); } }, - [apiKey] + [apiKey, onWalletInfoUpdated] ); const showManageDetails = hasInteractedManage || Boolean(walletInfo); @@ -342,11 +343,9 @@ export function CashuPaymentWorkflow({ )}
- handleApiKeyChange(event.target.value)} - placeholder='sk-...' - className='font-mono text-sm' + onApiKeyChange={handleApiKeyChange} onFocus={() => setHasInteractedManage(true)} />
diff --git a/ui/components/landing/key-info-details.tsx b/ui/components/landing/key-info-details.tsx index 053a51fa..dbadeb1c 100644 --- a/ui/components/landing/key-info-details.tsx +++ b/ui/components/landing/key-info-details.tsx @@ -4,6 +4,7 @@ import { type JSX, useState, useCallback, useEffect } from 'react'; import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react'; import type { RefundReceipt } from './cashu-payment-workflow'; import { toast } from 'sonner'; +import { ApiKeyInput } from '../api-key-input'; import { Card, CardContent, @@ -13,7 +14,6 @@ import { } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; import { WalletService } from '@/lib/api/services/wallet'; export type ChildKeyInfo = { @@ -188,12 +188,7 @@ export function KeyInfoDetails({
- handleKeyChange(e.target.value)} - placeholder='sk-...' - className='font-mono text-sm' - /> +
+
+
+ )} +
+ 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 && ( + + )} +
+
+
+ ))} +
+
+
+ )} +
+ ); +} From a1c2a785a1e69f320d2ef6ce44b575f597bb851b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 23 Mar 2026 22:36:16 +0100 Subject: [PATCH 03/10] better displaying errors --- ui/components/child-key-creator.tsx | 4 ++- ui/components/landing/key-info-details.tsx | 34 ++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index d82added..4db00e2d 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -159,7 +159,9 @@ export function ChildKeyCreator({ error instanceof Error ? error.message : 'Failed to create child key'; try { const parsed = JSON.parse(errorMessage); - errorMessage = parsed.detail || parsed.message || errorMessage; + errorMessage = + parsed.detail?.error?.message || + (typeof parsed.detail === 'string' ? parsed.detail : errorMessage); } catch {} setError(errorMessage); toast.error(errorMessage); diff --git a/ui/components/landing/key-info-details.tsx b/ui/components/landing/key-info-details.tsx index dbadeb1c..22be49b0 100644 --- a/ui/components/landing/key-info-details.tsx +++ b/ui/components/landing/key-info-details.tsx @@ -5,6 +5,7 @@ import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react'; import type { RefundReceipt } from './cashu-payment-workflow'; import { toast } from 'sonner'; import { ApiKeyInput } from '../api-key-input'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Card, CardContent, @@ -60,6 +61,7 @@ export function KeyInfoDetails({ const [isRefreshing, setIsRefreshing] = useState(false); const [isResetting, setIsResetting] = useState(null); const [isRefunding, setIsRefunding] = useState(false); + const [error, setError] = useState(null); // Sync internal state with props if they change useEffect(() => { @@ -69,12 +71,24 @@ export function KeyInfoDetails({ const fetchDetails = useCallback( async (keyToFetch: string) => { setIsRefreshing(true); + setError(null); 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 errorText = await response.text(); + let errorMessage = errorText || 'Failed to fetch key info'; + try { + const parsed = JSON.parse(errorText); + // The error structure seems to be { detail: { error: { message: ... } } } + errorMessage = + parsed.detail?.error?.message || + (typeof parsed.detail === 'string' + ? parsed.detail + : errorMessage); + } catch {} + throw new Error(errorMessage); } const payload = await response.json(); const snapshot: WalletSnapshot = { @@ -93,9 +107,9 @@ export function KeyInfoDetails({ onWalletInfoUpdated?.(snapshot); toast.success('Key details synced'); } catch (error) { - toast.error( - error instanceof Error ? error.message : 'Failed to fetch details' - ); + const message = + error instanceof Error ? error.message : 'Failed to fetch details'; + setError(message); } finally { setIsRefreshing(false); } @@ -103,13 +117,16 @@ export function KeyInfoDetails({ [baseUrl, onWalletInfoUpdated] ); - const handleRefresh = async () => { + const handleRefresh = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); if (!apiKeyInput) return; await fetchDetails(apiKeyInput); }; const handleKeyChange = (newKey: string) => { setApiKeyInput(newKey); + setError(null); onApiKeyChanged?.(newKey); // Optionally clear info when key changes if (newKey !== apiKey) { @@ -204,6 +221,7 @@ export function KeyInfoDetails({ className='min-w-[80px] gap-1' onClick={handleRefresh} disabled={isRefreshing || !apiKeyInput} + type='button' >
+ {error && ( + + Error + {error} + + )}
From bb97e8dedbee741bd1db21a08dddae832cc69961 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 25 Mar 2026 10:21:18 +0100 Subject: [PATCH 04/10] use react query --- ui/components/child-key-creator.tsx | 80 ++++++++---- .../landing/cashu-payment-workflow.tsx | 122 ++++++++---------- ui/components/landing/key-info-details.tsx | 79 +++--------- ui/hooks/use-wallet-info.ts | 38 ++++++ 4 files changed, 165 insertions(+), 154 deletions(-) create mode 100644 ui/hooks/use-wallet-info.ts diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index 4db00e2d..b6e5c324 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -1,6 +1,7 @@ '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'; @@ -15,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 { @@ -43,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, @@ -62,14 +62,18 @@ export function ChildKeyCreator({ }, ]); 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, refetch: refetchWalletInfo } = useWalletInfo( + baseUrl ?? '', + activeApiKey + ); + + const handleApiKeyChange = (val: string) => { + setInternalApiKey(val); + onApiKeyChange?.(val); + }; + const [newKeys, setNewKeys] = useState([]); const [resultInfo, setResultInfo] = useState<{ cost_msats: number; @@ -77,13 +81,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, @@ -269,6 +266,39 @@ export function ChildKeyCreator({ + {walletInfo && ( +
+
+ + Spendable Balance + + + {formatSats(walletInfo.balanceMsats)} sats + +
+
+ + Total Requests + + + {walletInfo.totalRequests} + +
+
+ + Total Spent + +
+

+ {formatSats(walletInfo.totalSpent)} sats +

+

+ {formatMsats(walletInfo.totalSpent)} msats +

+
+
+
+ )} )} diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index 5608833f..ccf03460 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -10,9 +10,9 @@ 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 { ApiKeyInput } from '../api-key-input'; import type { ChildKeyInfo, WalletSnapshot } from './key-info-details'; +import { useWalletInfo } from '@/hooks/use-wallet-info'; export type RefundReceipt = { token?: string; @@ -31,68 +31,18 @@ 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(); - let errorMessage = errorText || 'Unable to load wallet info'; - try { - const parsed = JSON.parse(errorText); - // The error structure seems to be { detail: { error: { message: ... } } } - errorMessage = - parsed.detail?.error?.message || - (typeof parsed.detail === 'string' ? parsed.detail : errorMessage); - } catch {} - throw new Error(errorMessage); - } - - 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, onWalletInfoUpdated, }: CashuPaymentWorkflowProps): JSX.Element { @@ -101,7 +51,6 @@ export function CashuPaymentWorkflow({ const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [isCreatingKey, setIsCreatingKey] = useState(false); const [isTopupLoading, setIsTopupLoading] = useState(false); - const [isSyncingBalance, setIsSyncingBalance] = useState(false); const [hasInteractedManage, setHasInteractedManage] = useState(false); const [hasInteractedTopup, setHasInteractedTopup] = useState(false); const [balanceLimit, setBalanceLimit] = useState(''); @@ -111,6 +60,13 @@ export function CashuPaymentWorkflow({ 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; @@ -211,11 +167,9 @@ 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); @@ -223,10 +177,8 @@ export function CashuPaymentWorkflow({ error instanceof Error ? error.message : 'Failed to sync balance'; setError(message); toast.error(message); - } finally { - setIsSyncingBalance(false); } - }, [activeApiKey, baseUrl, onWalletInfoUpdated]); + }, [activeApiKey, refetch]); const handleTopup = useCallback(async (): Promise => { if (!activeApiKey) { @@ -255,15 +207,14 @@ 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]); + }, [activeApiKey, baseUrl, topupToken, refetch]); const handleApiKeyChange = useCallback( (newKey: string) => { @@ -363,13 +314,48 @@ export function CashuPaymentWorkflow({ size='sm' className='gap-1' onClick={handleSyncBalance} - disabled={isSyncingBalance || !activeApiKey} + disabled={isFetching || !activeApiKey} > - + Sync + {walletInfo && ( +
+
+ + Spendable Balance + + + {formatSats(walletInfo.balanceMsats)} sats + +
+
+ + Total Requests + + + {walletInfo.totalRequests} + +
+
+ + Total Spent + +
+

+ {formatSats(walletInfo.totalSpent)} sats +

+

+ {formatMsats(walletInfo.totalSpent)} msats +

+
+
+
+ )} {error && ( Error diff --git a/ui/components/landing/key-info-details.tsx b/ui/components/landing/key-info-details.tsx index 22be49b0..4375d6a3 100644 --- a/ui/components/landing/key-info-details.tsx +++ b/ui/components/landing/key-info-details.tsx @@ -1,7 +1,6 @@ 'use client'; -import { type JSX, useState, useCallback, useEffect } from 'react'; -import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react'; +import { useWalletInfo } from '@/hooks/use-wallet-info'; import type { RefundReceipt } from './cashu-payment-workflow'; import { toast } from 'sonner'; import { ApiKeyInput } from '../api-key-input'; @@ -16,6 +15,8 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { WalletService } from '@/lib/api/services/wallet'; +import { useState, useCallback, useEffect } from 'react'; +import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react'; export type ChildKeyInfo = { api_key: string; @@ -52,76 +53,33 @@ interface KeyInfoDetailsProps { export function KeyInfoDetails({ baseUrl, apiKey = '', - walletInfo = null, + walletInfo: propWalletInfo = null, onApiKeyChanged, onWalletInfoUpdated, onRefundComplete, }: KeyInfoDetailsProps): JSX.Element { 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); - setError(null); - try { - const response = await fetch(`${baseUrl}/v1/balance/info`, { - headers: { Authorization: `Bearer ${keyToFetch}` }, - }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = errorText || 'Failed to fetch key info'; - try { - const parsed = JSON.parse(errorText); - // The error structure seems to be { detail: { error: { message: ... } } } - errorMessage = - parsed.detail?.error?.message || - (typeof parsed.detail === 'string' - ? parsed.detail - : errorMessage); - } catch {} - throw new Error(errorMessage); - } - 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) { - const message = - error instanceof Error ? error.message : 'Failed to fetch details'; - setError(message); - } finally { - setIsRefreshing(false); - } - }, - [baseUrl, onWalletInfoUpdated] - ); - const handleRefresh = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!apiKeyInput) return; - await fetchDetails(apiKeyInput); + await refetch(); }; const handleKeyChange = (newKey: string) => { @@ -146,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' @@ -176,16 +134,15 @@ export function KeyInfoDetails({ } const receipt = (await response.json()) as RefundReceipt; onRefundComplete?.(receipt); - // Removed onWalletInfoUpdated?.(null); // Prevents card disappearance toast.success('Refund completed'); - await fetchDetails(apiKeyInput); // Refresh to show burned status + await refetch(); } catch (error) { console.error(error); toast.error(error instanceof Error ? error.message : 'Refund failed'); } finally { setIsRefunding(false); } - }, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]); + }, [apiKeyInput, baseUrl, onRefundComplete, refetch]); const formatSats = (msats: number) => new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000)); @@ -220,13 +177,13 @@ export function KeyInfoDetails({ size='sm' className='min-w-[80px] gap-1' onClick={handleRefresh} - disabled={isRefreshing || !apiKeyInput} + disabled={isFetching || !apiKeyInput} type='button' > - {isRefreshing ? 'Syncing...' : 'Sync'} + {isFetching ? 'Syncing...' : 'Sync'} 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 + }); +} From 62185fbd38ad89d0abfead0ee9f5f1678ae50e52 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 25 Mar 2026 10:28:22 +0100 Subject: [PATCH 05/10] fix build --- ui/components/child-key-creator.tsx | 15 +++++++++++---- ui/components/landing/key-info-details.tsx | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index b6e5c324..bea75d81 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -28,6 +28,14 @@ interface KeyConfig { validityDate: string; } +interface KeyStatus { + total_spent: number; + balance_limit: number | null; + validity_date: number | null; + is_expired: boolean; + is_drained: boolean; +} + interface ChildKeyCreatorProps { baseUrl?: string; apiKey?: string; @@ -52,6 +60,8 @@ export function ChildKeyCreator({ const [internalApiKey, setInternalApiKey] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [checking, setChecking] = useState(false); + const [keyStatus, setKeyStatus] = useState(null); const [configs, setConfigs] = useState([ { id: crypto.randomUUID(), @@ -64,10 +74,7 @@ export function ChildKeyCreator({ const [childKeyToCheck, setChildKeyToCheck] = useState(''); const activeApiKey = propApiKey ?? internalApiKey; - const { data: walletInfo, refetch: refetchWalletInfo } = useWalletInfo( - baseUrl ?? '', - activeApiKey - ); + const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey); const handleApiKeyChange = (val: string) => { setInternalApiKey(val); diff --git a/ui/components/landing/key-info-details.tsx b/ui/components/landing/key-info-details.tsx index 4375d6a3..55935d81 100644 --- a/ui/components/landing/key-info-details.tsx +++ b/ui/components/landing/key-info-details.tsx @@ -15,7 +15,7 @@ import { import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { WalletService } from '@/lib/api/services/wallet'; -import { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react'; export type ChildKeyInfo = { @@ -57,7 +57,7 @@ export function KeyInfoDetails({ onApiKeyChanged, onWalletInfoUpdated, onRefundComplete, -}: KeyInfoDetailsProps): JSX.Element { +}: KeyInfoDetailsProps): React.ReactNode { const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [isResetting, setIsResetting] = useState(null); const [isRefunding, setIsRefunding] = useState(false); From c5a205cf981224aacc3ca3ab65572536407de33f Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 29 Mar 2026 10:54:17 +0200 Subject: [PATCH 06/10] clean up --- ui/components/child-key-creator.tsx | 52 ------------------- .../landing/cashu-payment-workflow.tsx | 6 +-- 2 files changed, 2 insertions(+), 56 deletions(-) diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx index bea75d81..2f462179 100644 --- a/ui/components/child-key-creator.tsx +++ b/ui/components/child-key-creator.tsx @@ -28,14 +28,6 @@ interface KeyConfig { validityDate: string; } -interface KeyStatus { - total_spent: number; - balance_limit: number | null; - validity_date: number | null; - is_expired: boolean; - is_drained: boolean; -} - interface ChildKeyCreatorProps { baseUrl?: string; apiKey?: string; @@ -60,8 +52,6 @@ export function ChildKeyCreator({ const [internalApiKey, setInternalApiKey] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [checking, setChecking] = useState(false); - const [keyStatus, setKeyStatus] = useState(null); const [configs, setConfigs] = useState([ { id: crypto.randomUUID(), @@ -71,7 +61,6 @@ export function ChildKeyCreator({ validityDate: '', }, ]); - const [childKeyToCheck, setChildKeyToCheck] = useState(''); const activeApiKey = propApiKey ?? internalApiKey; const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey); @@ -174,47 +163,6 @@ 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); diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index ccf03460..bce94026 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -1,17 +1,16 @@ '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 { ApiKeyInput } from '../api-key-input'; -import type { ChildKeyInfo, WalletSnapshot } from './key-info-details'; +import type { WalletSnapshot } from './key-info-details'; import { useWalletInfo } from '@/hooks/use-wallet-info'; export type RefundReceipt = { @@ -226,7 +225,6 @@ export function CashuPaymentWorkflow({ [apiKey, onWalletInfoUpdated] ); - const showManageDetails = hasInteractedManage || Boolean(walletInfo); const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0; const canTopup = Boolean(activeApiKey); const showCreateDetails = initialToken.trim().length > 0; From a833cf429e042efe09cf0991a5b8b1daca24d4df Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 29 Mar 2026 21:22:09 +0200 Subject: [PATCH 07/10] add refund x-cashu to default refund endpoint --- routstr/balance.py | 19 +++++++ tests/unit/test_balance.py | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/unit/test_balance.py diff --git a/routstr/balance.py b/routstr/balance.py index 9adbd9b7..6eed34aa 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 @@ -207,6 +208,7 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None: @router.post("/refund") async def refund_wallet_endpoint( authorization: Annotated[str, Header(...)], + x_cashu: Annotated[str | None, Header()] = None, session: AsyncSession = Depends(get_session), ) -> dict[str, str]: if not authorization.startswith("Bearer "): @@ -217,6 +219,23 @@ async def refund_wallet_endpoint( bearer_value: str = authorization[7:] + if x_cashu: + payment_token_hash = hashlib.sha256(x_cashu.strip().encode()).hexdigest() + result = await session.get(CashuTransaction, payment_token_hash) + if result is None: + raise HTTPException(status_code=404, detail="Refund not found") + if result.swept: + raise HTTPException(status_code=410, detail="Refund has been swept") + result.collected = True + session.add(result) + await session.commit() + body: dict[str, str] = {"token": result.token} + if result.unit == "sat": + body["sats"] = str(result.amount) + else: + body["msats"] = str(result.amount) + return JSONResponse(content=body, headers={"X-Cashu": result.token}) + 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..fdb4d0fc --- /dev/null +++ b/tests/unit/test_balance.py @@ -0,0 +1,100 @@ +import hashlib +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from routstr.balance import refund_wallet_endpoint +from routstr.core.db import CashuTransaction + + +def _make_cashu_tx(token: str, amount: int, unit: str, swept: bool = False) -> CashuTransaction: + tx = CashuTransaction(token=token, amount=amount, unit=unit) + tx.swept = swept + tx.collected = False + return tx + + +@pytest.mark.asyncio +async def test_refund_x_cashu_returns_token() -> None: + x_cashu_token = "cashuAtest_token_value" + expected_hash = hashlib.sha256(x_cashu_token.strip().encode()).hexdigest() + tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat") + + session = MagicMock() + session.get = AsyncMock(return_value=tx) + session.add = MagicMock() + session.commit = AsyncMock() + + result = await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu=x_cashu_token, + session=session, + ) + + session.get.assert_awaited_once_with(CashuTransaction, expected_hash) + import json + body = json.loads(result.body) + assert body["token"] == "cashuArefund_token" + assert body["msats"] == "1000" + assert result.headers["X-Cashu"] == "cashuArefund_token" + assert tx.collected is True + + +@pytest.mark.asyncio +async def test_refund_x_cashu_sat_unit() -> None: + x_cashu_token = "cashuAsat_token" + tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat") + + session = MagicMock() + session.get = AsyncMock(return_value=tx) + session.add = MagicMock() + session.commit = AsyncMock() + + result = await refund_wallet_endpoint( + authorization="Bearer sk-somekey", + x_cashu=x_cashu_token, + session=session, + ) + + import json + 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.get = AsyncMock(return_value=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 + + tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", swept=True) + + session = MagicMock() + session.get = AsyncMock(return_value=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 From 6fde846be277ba754e74e4834ba68cb618bc07fd Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 1 Apr 2026 15:04:38 +0200 Subject: [PATCH 08/10] refund x cashu --- routstr/balance.py | 62 +++++++++++++++++++++++++------------- tests/unit/test_balance.py | 6 ++++ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 6eed34aa..d8ec98f5 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -207,35 +207,55 @@ async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None: @router.post("/refund") 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:] - - if x_cashu: - payment_token_hash = hashlib.sha256(x_cashu.strip().encode()).hexdigest() - result = await session.get(CashuTransaction, payment_token_hash) - if result is None: - raise HTTPException(status_code=404, detail="Refund not found") - if result.swept: - raise HTTPException(status_code=410, detail="Refund has been swept") - result.collected = True - session.add(result) - await session.commit() - body: dict[str, str] = {"token": result.token} - if result.unit == "sat": - body["sats"] = str(result.amount) - else: - body["msats"] = str(result.amount) - return JSONResponse(content=body, headers={"X-Cashu": result.token}) - 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 index fdb4d0fc..f36ef158 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -33,6 +33,9 @@ async def test_refund_x_cashu_returns_token() -> None: session.get.assert_awaited_once_with(CashuTransaction, expected_hash) import json + + from fastapi.responses import JSONResponse + assert isinstance(result, JSONResponse) body = json.loads(result.body) assert body["token"] == "cashuArefund_token" assert body["msats"] == "1000" @@ -57,6 +60,9 @@ async def test_refund_x_cashu_sat_unit() -> None: ) import json + + from fastapi.responses import JSONResponse + assert isinstance(result, JSONResponse) body = json.loads(result.body) assert body["token"] == "cashuArefund_sat" assert body["sats"] == "500" From 905ae670151c6f3fb8affbda4212e0cd65e2e46c Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 1 Apr 2026 15:11:43 +0200 Subject: [PATCH 09/10] update tests --- routstr/balance.py | 2 +- tests/unit/test_balance.py | 50 +++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index d8ec98f5..e63f2cc3 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -205,7 +205,7 @@ 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 | None, Header()] = None, x_cashu: Annotated[str | None, Header()] = None, diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index f36ef158..b9ff980d 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -1,27 +1,42 @@ -import hashlib +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, swept: bool = False) -> CashuTransaction: - tx = CashuTransaction(token=token, amount=amount, unit=unit) +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 = False + 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" - expected_hash = hashlib.sha256(x_cashu_token.strip().encode()).hexdigest() - tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat") + 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.get = AsyncMock(return_value=tx) + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) session.add = MagicMock() session.commit = AsyncMock() @@ -31,25 +46,22 @@ async def test_refund_x_cashu_returns_token() -> None: session=session, ) - session.get.assert_awaited_once_with(CashuTransaction, expected_hash) - import json - - from fastapi.responses import JSONResponse 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 tx.collected is True + assert out_tx.collected is True @pytest.mark.asyncio async def test_refund_x_cashu_sat_unit() -> None: x_cashu_token = "cashuAsat_token" - tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat") + 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.get = AsyncMock(return_value=tx) + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) session.add = MagicMock() session.commit = AsyncMock() @@ -59,9 +71,6 @@ async def test_refund_x_cashu_sat_unit() -> None: session=session, ) - import json - - from fastapi.responses import JSONResponse assert isinstance(result, JSONResponse) body = json.loads(result.body) assert body["token"] == "cashuArefund_sat" @@ -75,7 +84,7 @@ async def test_refund_x_cashu_not_found_raises_404() -> None: from fastapi import HTTPException session = MagicMock() - session.get = AsyncMock(return_value=None) + session.exec = AsyncMock(return_value=_exec_result(None)) with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -91,10 +100,11 @@ async def test_refund_x_cashu_not_found_raises_404() -> None: async def test_refund_x_cashu_swept_raises_410() -> None: from fastapi import HTTPException - tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", swept=True) + 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.get = AsyncMock(return_value=tx) + session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( From a68998e8ffecb8dee71c69dd91a138f915260cfc Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 1 Apr 2026 15:13:38 +0200 Subject: [PATCH 10/10] clean up --- ui/components/landing/cashu-payment-workflow.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/components/landing/cashu-payment-workflow.tsx b/ui/components/landing/cashu-payment-workflow.tsx index bce94026..e2244ea0 100644 --- a/ui/components/landing/cashu-payment-workflow.tsx +++ b/ui/components/landing/cashu-payment-workflow.tsx @@ -50,7 +50,6 @@ export function CashuPaymentWorkflow({ const [apiKeyInput, setApiKeyInput] = useState(apiKey); const [isCreatingKey, setIsCreatingKey] = useState(false); const [isTopupLoading, setIsTopupLoading] = useState(false); - const [hasInteractedManage, setHasInteractedManage] = useState(false); const [hasInteractedTopup, setHasInteractedTopup] = useState(false); const [balanceLimit, setBalanceLimit] = useState(''); const [balanceLimitReset, setBalanceLimitReset] = useState(''); @@ -295,7 +294,6 @@ export function CashuPaymentWorkflow({ setHasInteractedManage(true)} />